1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
// Memory hygiene & retention (session pruning, expired-record reaping,
// exchange retention, project chunk caps, global-tier retention, cleanup log).
/// Rows deleted per statement while reaping so the connection mutex is
/// released between batches and stays responsive for foreground queries.
const HYGIENE_DELETE_BATCH: usize = 500;
impl MemoryDatabase {
/// Delete session memory chunks older than `retention_days` days.
///
/// Also removes orphaned vector entries for the deleted chunks so the
/// sqlite-vec virtual table stays consistent.
///
/// Returns the number of chunk rows deleted.
/// If `retention_days` is 0 hygiene is disabled and this returns Ok(0).
pub async fn prune_old_session_chunks(&self, retention_days: u32) -> MemoryResult<u64> {
self.prune_old_session_chunks_for_tenant(retention_days, &MemoryTenantScope::local())
.await
}
pub async fn prune_old_session_chunks_for_tenant(
&self,
retention_days: u32,
tenant_scope: &MemoryTenantScope,
) -> MemoryResult<u64> {
if retention_days == 0 {
return Ok(0);
}
let conn = self.conn.lock().await;
// WAL is already active (set in new()) — no need to set it again here.
let cutoff =
(chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days))).to_rfc3339();
// Remove orphaned vector entries first (chunk_id FK would dangle otherwise)
conn.execute(
"DELETE FROM session_memory_vectors
WHERE chunk_id IN (
SELECT id FROM session_memory_chunks
WHERE created_at < ?1
AND tenant_org_id = ?2
AND tenant_workspace_id = ?3
AND IFNULL(tenant_deployment_id, '') = IFNULL(?4, '')
)",
params![
cutoff,
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref()
],
)?;
let deleted = conn.execute(
"DELETE FROM session_memory_chunks
WHERE created_at < ?1
AND tenant_org_id = ?2
AND tenant_workspace_id = ?3
AND IFNULL(tenant_deployment_id, '') = IFNULL(?4, '')",
params![
cutoff,
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref()
],
)?;
if deleted > 0 {
tracing::info!(
retention_days,
deleted,
"memory hygiene: pruned old session chunks"
);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Ok(deleted as u64)
}
/// Delete global-tier memory chunks (and their paired vector rows) older
/// than `retention_days` days. 0 disables age-pruning and returns Ok(0) —
/// global chunks are user-facing archived memory, so this is opt-in.
pub async fn prune_old_global_chunks_for_tenant(
&self,
retention_days: u32,
tenant_scope: &MemoryTenantScope,
) -> MemoryResult<u64> {
if retention_days == 0 {
return Ok(0);
}
let conn = self.conn.lock().await;
let cutoff =
(chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days))).to_rfc3339();
conn.execute(
"DELETE FROM global_memory_vectors
WHERE chunk_id IN (
SELECT id FROM global_memory_chunks
WHERE created_at < ?1
AND tenant_org_id = ?2
AND tenant_workspace_id = ?3
AND IFNULL(tenant_deployment_id, '') = IFNULL(?4, '')
)",
params![
cutoff,
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref()
],
)?;
let deleted = conn.execute(
"DELETE FROM global_memory_chunks
WHERE created_at < ?1
AND tenant_org_id = ?2
AND tenant_workspace_id = ?3
AND IFNULL(tenant_deployment_id, '') = IFNULL(?4, '')",
params![
cutoff,
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref()
],
)?;
if deleted > 0 {
tracing::info!(
retention_days,
deleted,
"memory hygiene: pruned old global chunks"
);
}
Ok(deleted as u64)
}
/// Hard-delete `memory_records` rows whose TTL has elapsed. These rows are
/// already invisible to every read path (`expires_at_ms IS NULL OR
/// expires_at_ms > now` filters), so this is pure space reclamation. The
/// FTS mirror is kept in sync by the AD trigger.
pub async fn reap_expired_memory_records_for_tenant(
&self,
tenant_scope: &MemoryTenantScope,
) -> MemoryResult<u64> {
let now_ms = chrono::Utc::now().timestamp_millis();
let mut total = 0u64;
loop {
let deleted = {
let conn = self.conn.lock().await;
conn.execute(
"DELETE FROM memory_records
WHERE id IN (
SELECT id FROM memory_records
WHERE expires_at_ms IS NOT NULL
AND expires_at_ms <= ?1
AND tenant_org_id = ?2
AND tenant_workspace_id = ?3
AND IFNULL(tenant_deployment_id, '') = IFNULL(?4, '')
LIMIT ?5
)",
params![
now_ms,
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref(),
HYGIENE_DELETE_BATCH as i64
],
)?
};
total += deleted as u64;
if deleted < HYGIENE_DELETE_BATCH {
break;
}
}
if total > 0 {
tracing::info!(deleted = total, "memory hygiene: reaped expired records");
}
Ok(total)
}
/// Delete raw chat exchange records (`user_message`/`assistant_final`)
/// older than `retention_days` days by `created_at_ms`.
/// 0 = keep forever (returns Ok(0)).
pub async fn prune_exchange_memory_records_for_tenant(
&self,
retention_days: i64,
tenant_scope: &MemoryTenantScope,
) -> MemoryResult<u64> {
if retention_days <= 0 {
return Ok(0);
}
let cutoff_ms =
(chrono::Utc::now() - chrono::Duration::days(retention_days)).timestamp_millis();
let mut total = 0u64;
loop {
let deleted = {
let conn = self.conn.lock().await;
conn.execute(
"DELETE FROM memory_records
WHERE id IN (
SELECT id FROM memory_records
WHERE source_type IN ('user_message', 'assistant_final')
AND created_at_ms < ?1
AND tenant_org_id = ?2
AND tenant_workspace_id = ?3
AND IFNULL(tenant_deployment_id, '') = IFNULL(?4, '')
LIMIT ?5
)",
params![
cutoff_ms,
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref(),
HYGIENE_DELETE_BATCH as i64
],
)?
};
total += deleted as u64;
if deleted < HYGIENE_DELETE_BATCH {
break;
}
}
if total > 0 {
tracing::info!(
retention_days,
deleted = total,
"memory hygiene: pruned old exchange records"
);
}
Ok(total)
}
/// Evict the oldest project-tier chunks for `project_id` until the count
/// is back at `max_chunks`. Each batch deletes the chunk row and its
/// paired vector row in one transaction. `max_chunks <= 0` means no cap.
pub async fn enforce_project_chunk_cap_for_tenant(
&self,
project_id: &str,
max_chunks: i64,
tenant_scope: &MemoryTenantScope,
) -> MemoryResult<u64> {
if max_chunks <= 0 {
return Ok(0);
}
let mut total = 0u64;
loop {
let mut conn = self.conn.lock().await;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM project_memory_chunks
WHERE project_id = ?1
AND tenant_org_id = ?2
AND tenant_workspace_id = ?3
AND IFNULL(tenant_deployment_id, '') = IFNULL(?4, '')",
params![
project_id,
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref()
],
|row| row.get(0),
)?;
let excess = count - max_chunks;
if excess <= 0 {
break;
}
let batch = excess.min(HYGIENE_DELETE_BATCH as i64);
let oldest_sql = "SELECT id FROM project_memory_chunks
WHERE project_id = ?1
AND tenant_org_id = ?2
AND tenant_workspace_id = ?3
AND IFNULL(tenant_deployment_id, '') = IFNULL(?4, '')
ORDER BY created_at ASC, id ASC
LIMIT ?5";
let tx = conn.transaction()?;
// Vector rows first: once the chunk rows are gone the subquery can
// no longer name the orphaned vector entries.
tx.execute(
&format!(
"DELETE FROM project_memory_vectors WHERE chunk_id IN ({oldest_sql})"
),
params![
project_id,
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref(),
batch
],
)?;
let deleted = tx.execute(
&format!("DELETE FROM project_memory_chunks WHERE id IN ({oldest_sql})"),
params![
project_id,
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref(),
batch
],
)?;
tx.commit()?;
total += deleted as u64;
if deleted == 0 {
break;
}
}
if total > 0 {
tracing::info!(
project_id,
max_chunks,
evicted = total,
"memory hygiene: evicted oldest project chunks over cap"
);
}
Ok(total)
}
/// Run scheduled hygiene for a tenant scope:
/// - prune session chunks older than `session_retention_days` (env
/// override wins when non-zero, else the `__global__` config row,
/// else 30 days),
/// - reap expired `memory_records` (TTL elapsed),
/// - prune raw chat exchange records past `exchange_retention_days`,
/// - enforce per-project `max_chunks` caps (oldest-first eviction),
/// - age-prune global-tier chunks when `global_retention_days` is set.
///
/// Every deletion writes a `memory_cleanup_log` row. Returns the total
/// number of rows deleted. This method is intentionally best-effort —
/// callers should log errors and continue.
pub async fn run_hygiene(&self, env_override_days: u32) -> MemoryResult<u64> {
self.run_hygiene_for_tenant(env_override_days, &MemoryTenantScope::local())
.await
}
/// Enumerate every tenant scope that has memory rows in any table the
/// reapers touch. The local scope is always included so a fresh database
/// with no rows yet still gets its hygiene pass.
pub async fn list_memory_tenant_scopes(&self) -> MemoryResult<Vec<MemoryTenantScope>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT DISTINCT tenant_org_id, tenant_workspace_id, tenant_deployment_id
FROM session_memory_chunks
UNION
SELECT DISTINCT tenant_org_id, tenant_workspace_id, tenant_deployment_id
FROM project_memory_chunks
UNION
SELECT DISTINCT tenant_org_id, tenant_workspace_id, tenant_deployment_id
FROM global_memory_chunks
UNION
SELECT DISTINCT tenant_org_id, tenant_workspace_id, tenant_deployment_id
FROM memory_records",
)?;
let rows = stmt.query_map([], |row| {
Ok(MemoryTenantScope {
org_id: row.get(0)?,
workspace_id: row.get(1)?,
deployment_id: row.get(2)?,
})
})?;
let mut scopes = rows.collect::<Result<Vec<_>, _>>()?;
drop(stmt);
drop(conn);
let local = MemoryTenantScope::local();
if !scopes.contains(&local) {
scopes.push(local);
}
Ok(scopes)
}
/// Run scheduled hygiene for every tenant scope present in the database
/// (hosted/enterprise deployments stamp real tenant scopes on rows, which
/// the local-scope wrapper above would never touch). Per-scope failures
/// are logged and skipped so one bad partition cannot starve the rest.
/// Returns the total number of rows deleted across all scopes.
pub async fn run_hygiene_all_tenants(&self, env_override_days: u32) -> MemoryResult<u64> {
let mut total = 0u64;
for scope in self.list_memory_tenant_scopes().await? {
match self.run_hygiene_for_tenant(env_override_days, &scope).await {
Ok(deleted) => total += deleted,
Err(error) => tracing::warn!(
tenant_org_id = %scope.org_id,
tenant_workspace_id = %scope.workspace_id,
%error,
"memory hygiene failed for tenant scope"
),
}
}
Ok(total)
}
pub async fn run_hygiene_for_tenant(
&self,
env_override_days: u32,
tenant_scope: &MemoryTenantScope,
) -> MemoryResult<u64> {
let defaults = MemoryConfig::default();
// Read the global (project_id = '__global__') config row if present.
let global_config: Option<(i64, i64, i64)> = {
let conn = self.conn.lock().await;
conn.query_row(
"SELECT session_retention_days, exchange_retention_days, global_retention_days
FROM memory_config
WHERE project_id = '__global__'
AND tenant_org_id = ?1
AND tenant_workspace_id = ?2
AND IFNULL(tenant_deployment_id, '') = IFNULL(?3, '')
LIMIT 1",
params![
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref()
],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.ok()
};
let (config_session_days, exchange_retention_days, global_retention_days) = global_config
.unwrap_or((
defaults.session_retention_days,
defaults.exchange_retention_days,
defaults.global_retention_days,
));
// Prefer the env override, fall back to the DB config for the null project.
let session_retention_days = if env_override_days > 0 {
env_override_days
} else {
config_session_days.max(0) as u32
};
let mut total = 0u64;
let session_pruned = self
.prune_old_session_chunks_for_tenant(session_retention_days, tenant_scope)
.await?;
if session_pruned > 0 {
self.log_cleanup_for_tenant(
"hygiene_session_retention",
MemoryTier::Session,
None,
None,
session_pruned as i64,
0,
tenant_scope,
)
.await?;
}
total += session_pruned;
let expired_reaped = self
.reap_expired_memory_records_for_tenant(tenant_scope)
.await?;
if expired_reaped > 0 {
self.log_cleanup_for_tenant(
"hygiene_expired_records",
MemoryTier::Global,
None,
None,
expired_reaped as i64,
0,
tenant_scope,
)
.await?;
}
total += expired_reaped;
let exchanges_pruned = self
.prune_exchange_memory_records_for_tenant(exchange_retention_days, tenant_scope)
.await?;
if exchanges_pruned > 0 {
self.log_cleanup_for_tenant(
"hygiene_exchange_retention",
MemoryTier::Global,
None,
None,
exchanges_pruned as i64,
0,
tenant_scope,
)
.await?;
}
total += exchanges_pruned;
// Enforce the project-tier chunk cap for every project in this tenant,
// using the project's own config row when one exists.
let project_ids: Vec<String> = {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT DISTINCT project_id FROM project_memory_chunks
WHERE tenant_org_id = ?1
AND tenant_workspace_id = ?2
AND IFNULL(tenant_deployment_id, '') = IFNULL(?3, '')",
)?;
let rows = stmt.query_map(
params![
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref()
],
|row| row.get::<_, String>(0),
)?;
rows.collect::<Result<Vec<_>, _>>()?
};
for project_id in project_ids {
let max_chunks: i64 = {
let conn = self.conn.lock().await;
conn.query_row(
"SELECT max_chunks FROM memory_config
WHERE project_id = ?1
AND tenant_org_id = ?2
AND tenant_workspace_id = ?3
AND IFNULL(tenant_deployment_id, '') = IFNULL(?4, '')",
params![
project_id,
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref()
],
|row| row.get(0),
)
.optional()?
.unwrap_or(defaults.max_chunks)
};
let evicted = self
.enforce_project_chunk_cap_for_tenant(&project_id, max_chunks, tenant_scope)
.await?;
if evicted > 0 {
self.log_cleanup_for_tenant(
"hygiene_project_cap",
MemoryTier::Project,
Some(&project_id),
None,
evicted as i64,
0,
tenant_scope,
)
.await?;
}
total += evicted;
}
let global_pruned = self
.prune_old_global_chunks_for_tenant(global_retention_days.max(0) as u32, tenant_scope)
.await?;
if global_pruned > 0 {
self.log_cleanup_for_tenant(
"hygiene_global_retention",
MemoryTier::Global,
None,
None,
global_pruned as i64,
0,
tenant_scope,
)
.await?;
}
total += global_pruned;
Ok(total)
}
/// Read recent cleanup log entries (newest first).
pub async fn get_cleanup_log(&self, limit: i64) -> MemoryResult<Vec<CleanupLogEntry>> {
self.get_cleanup_log_for_tenant(limit, &MemoryTenantScope::local())
.await
}
pub async fn get_cleanup_log_for_tenant(
&self,
limit: i64,
tenant_scope: &MemoryTenantScope,
) -> MemoryResult<Vec<CleanupLogEntry>> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare(
"SELECT id, cleanup_type, tier, project_id, session_id,
chunks_deleted, bytes_reclaimed, created_at
FROM memory_cleanup_log
WHERE tenant_org_id = ?1
AND tenant_workspace_id = ?2
AND IFNULL(tenant_deployment_id, '') = IFNULL(?3, '')
ORDER BY created_at DESC
LIMIT ?4",
)?;
let rows = stmt.query_map(
params![
tenant_scope.org_id.as_str(),
tenant_scope.workspace_id.as_str(),
tenant_scope.deployment_id.as_deref(),
limit.clamp(1, 1000)
],
|row| {
let tier = match row.get::<_, String>(2)?.as_str() {
"session" => MemoryTier::Session,
"project" => MemoryTier::Project,
_ => MemoryTier::Global,
};
let created_at = DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?)
.map(|value| value.with_timezone(&Utc))
.unwrap_or_default();
Ok(CleanupLogEntry {
id: row.get(0)?,
cleanup_type: row.get(1)?,
tier,
project_id: row.get(3)?,
session_id: row.get(4)?,
chunks_deleted: row.get(5)?,
bytes_reclaimed: row.get(6)?,
created_at,
})
},
)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(MemoryError::Database)
}
pub async fn delete_global_memory(&self, id: &str) -> MemoryResult<bool> {
let conn = self.conn.lock().await;
let changed = conn.execute("DELETE FROM memory_records WHERE id = ?1", params![id])?;
Ok(changed > 0)
}
pub async fn delete_global_memory_for_tenant(
&self,
id: &str,
tenant_org_id: &str,
tenant_workspace_id: &str,
tenant_deployment_id: Option<&str>,
) -> MemoryResult<bool> {
let conn = self.conn.lock().await;
let changed = conn.execute(
"DELETE FROM memory_records
WHERE id = ?1
AND tenant_org_id = ?2
AND tenant_workspace_id = ?3
AND IFNULL(tenant_deployment_id, '') = IFNULL(?4, '')",
params![id, tenant_org_id, tenant_workspace_id, tenant_deployment_id],
)?;
Ok(changed > 0)
}
}