1use std::sync::Arc;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use tracing::{info, warn};
16
17use ailake_catalog::{
18 make_multi_column_data_file_entry, new_snapshot_id, CatalogProvider, ExtraVectorIndex,
19 NewSnapshot, SnapshotOperation, TableIdent, VectorIndexInfo,
20};
21use ailake_core::{AilakeError, AilakeResult, VectorStoragePolicy};
22use ailake_file::{AilakeFileReader, AilakeFileWriter, VectorColumnBatch};
23use ailake_store::Store;
24use ailake_vec::compute_centroid_and_radius;
25use arrow_array::{
26 Array, Float32Array, RecordBatch, TimestampMicrosecondArray, TimestampNanosecondArray,
27};
28use arrow_schema::{DataType, Field};
29
30const LAST_ACCESSED_COL: &str = "last_accessed_at";
31const RECENCY_WEIGHT_COL: &str = "recency_weight";
32
33pub struct MemoryDecayJob {
49 catalog: Arc<dyn CatalogProvider>,
50 store: Arc<dyn Store>,
51 policy: VectorStoragePolicy,
52 pub decay_lambda: f32,
55}
56
57impl MemoryDecayJob {
58 pub fn new(
59 catalog: Arc<dyn CatalogProvider>,
60 store: Arc<dyn Store>,
61 policy: VectorStoragePolicy,
62 decay_lambda: f32,
63 ) -> Self {
64 Self {
65 catalog,
66 store,
67 policy,
68 decay_lambda,
69 }
70 }
71
72 pub async fn run(&self, table: &TableIdent) -> AilakeResult<usize> {
77 let files = self.catalog.list_files(table, None).await?;
78 if files.is_empty() {
79 return Ok(0);
80 }
81
82 let today_day = current_day_since_epoch();
83 let mut new_entries = Vec::with_capacity(files.len());
84 let mut retired_paths: Vec<String> = Vec::new();
85 let mut updated = 0usize;
86
87 for file_entry in &files {
88 let file_bytes = self.store.get(&file_entry.path).await?;
89 let orig_bytes = file_bytes.clone();
91 let reader =
92 AilakeFileReader::new(file_bytes, &self.policy.column_name, self.policy.dim);
93
94 if !reader.is_ailake_file() {
95 new_entries.push(file_entry.clone());
97 continue;
98 }
99
100 let (batch, embeddings) = match reader.read_parquet() {
101 Ok(pair) => pair,
102 Err(e) => {
103 warn!(
104 "ailake: MemoryDecayJob skipping {} — read error: {}",
105 file_entry.path, e
106 );
107 new_entries.push(file_entry.clone());
108 continue;
109 }
110 };
111
112 if batch.column_by_name(LAST_ACCESSED_COL).is_none() {
113 new_entries.push(file_entry.clone());
115 continue;
116 }
117
118 let updated_batch = apply_decay(&batch, today_day, self.decay_lambda)?;
119
120 let extra_embeddings: Vec<(String, u32, Vec<Vec<f32>>)> = file_entry
122 .extra_vector_indexes
123 .iter()
124 .filter_map(|xi| {
125 let r = AilakeFileReader::new(orig_bytes.clone(), &xi.column, xi.dim);
126 r.read_parquet()
127 .ok()
128 .map(|(_, embs)| (xi.column.clone(), xi.dim, embs))
129 })
130 .collect();
131
132 let file_writer = AilakeFileWriter::new(self.policy.clone());
134 let new_bytes = if extra_embeddings.is_empty() {
135 file_writer.write(&updated_batch, &embeddings)?
136 } else {
137 let extra_policies: Vec<VectorStoragePolicy> = extra_embeddings
139 .iter()
140 .map(|(col, dim, _)| VectorStoragePolicy {
141 column_name: col.clone(),
142 dim: *dim,
143 metric: self.policy.metric,
144 precision: self.policy.precision,
145 pq: None,
146 keep_raw_for_reranking: false,
147 pre_normalize: false,
148 hnsw_m: None,
149 hnsw_ef_construction: None,
150 ivf_residual: false,
151 embedding_model: None,
152 modality: None,
153 partition_by: None,
154 partition_value: None,
155 partition_column_type: None,
156 partition_fields: vec![],
157 })
158 .collect();
159 let primary_col = VectorColumnBatch {
160 policy: &self.policy,
161 embeddings: &embeddings,
162 };
163 let mut col_batches: Vec<VectorColumnBatch<'_>> = vec![primary_col];
164 for (policy, (_, _, embs)) in extra_policies.iter().zip(extra_embeddings.iter()) {
165 col_batches.push(VectorColumnBatch {
166 policy,
167 embeddings: embs,
168 });
169 }
170 file_writer.write_multi(&updated_batch, &col_batches)?
171 };
172 let new_size = new_bytes.len() as u64;
173 let new_bytes_ref = new_bytes.clone();
175 let new_path = format!(
182 "data/decayed-{}-{:05}.parquet",
183 std::time::SystemTime::now()
184 .duration_since(std::time::UNIX_EPOCH)
185 .unwrap_or_else(|e| e.duration())
186 .as_millis(),
187 updated
188 );
189 self.store.put(&new_path, new_bytes.clone()).await?;
190
191 let centroid = compute_centroid_and_radius(&embeddings, self.policy.metric);
192 let new_reader =
193 AilakeFileReader::new(new_bytes, &self.policy.column_name, self.policy.dim);
194 let header = new_reader.read_header()?;
195 let ailk_start = new_reader.ailk_offset()?;
196
197 let new_extra: Vec<ExtraVectorIndex> = extra_embeddings
199 .iter()
200 .filter_map(|(col, dim, _)| {
201 let xr = AilakeFileReader::new(new_bytes_ref.clone(), col, *dim);
202 let xailk = xr.ailk_offset_for_column(col).ok()?;
203 let xhdr = xr.read_header_for_column(col).ok()?;
204 Some(ExtraVectorIndex {
205 column: col.clone(),
206 dim: *dim,
207 hnsw_offset: xailk + xhdr.hnsw_offset,
208 hnsw_len: xhdr.hnsw_len,
209 centroid_b64: None,
210 radius: None,
211 })
212 })
213 .collect();
214
215 let mut new_entry = make_multi_column_data_file_entry(
216 &new_path,
217 updated_batch.num_rows() as u64,
218 new_size,
219 ¢roid,
220 VectorIndexInfo {
221 column: &self.policy.column_name,
222 dim: self.policy.dim,
223 hnsw_offset: ailk_start + header.hnsw_offset,
224 hnsw_len: header.hnsw_len,
225 },
226 &new_extra,
227 );
228 new_entry.deletion_vector = file_entry.deletion_vector.clone();
235 new_entries.push(new_entry);
236 retired_paths.push(file_entry.path.clone());
237 updated += 1;
238 }
239
240 if updated == 0 {
241 info!(
242 "ailake: MemoryDecayJob — no files with last_accessed_at column; skipping commit"
243 );
244 return Ok(0);
245 }
246
247 let parent_snapshot_id = self
248 .catalog
249 .load_table(table)
250 .await
251 .ok()
252 .and_then(|m| m.current_snapshot_id);
253 let snap = NewSnapshot {
254 snapshot_id: new_snapshot_id(),
255 parent_snapshot_id,
256 files: new_entries,
257 operation: SnapshotOperation::Overwrite,
258 iceberg_schema: None,
259 extra_properties: std::collections::HashMap::new(),
260 bloom_filters: vec![],
261 equality_delete_files: vec![],
262 };
263 self.catalog.commit_snapshot(table, snap).await?;
264 if self.catalog.retires_files_physically() {
270 for path in &retired_paths {
271 if let Err(e) = self.store.delete(path).await {
272 warn!(
273 "ailake: MemoryDecayJob cleanup — could not delete retired {}: {} \
274 (orphan file; delete manually to reclaim storage)",
275 path, e
276 );
277 }
278 }
279 }
280 info!(
281 "ailake: MemoryDecayJob — updated recency_weight in {} files (lambda={})",
282 updated, self.decay_lambda
283 );
284 Ok(updated)
285 }
286}
287
288fn days_old_vec(col: &Arc<dyn Array>, today_day: i64) -> AilakeResult<Vec<f32>> {
290 if let Some(ts) = col.as_any().downcast_ref::<TimestampNanosecondArray>() {
291 return Ok((0..ts.len())
292 .map(|i| {
293 if !ts.is_valid(i) {
294 return 0.0f32;
295 }
296 let day = ts.value(i) / (86_400 * 1_000_000_000i64);
297 (today_day - day).max(0) as f32
298 })
299 .collect());
300 }
301 if let Some(ts) = col.as_any().downcast_ref::<TimestampMicrosecondArray>() {
302 return Ok((0..ts.len())
303 .map(|i| {
304 if !ts.is_valid(i) {
305 return 0.0f32;
306 }
307 let day = ts.value(i) / (86_400 * 1_000_000i64);
308 (today_day - day).max(0) as f32
309 })
310 .collect());
311 }
312 if let Some(sa) = col.as_any().downcast_ref::<arrow_array::StringArray>() {
313 return Ok((0..sa.len())
314 .map(|i| {
315 if !sa.is_valid(i) {
316 return 0.0f32;
317 }
318 let access_day = parse_iso_date_days(sa.value(i)).unwrap_or(today_day);
319 (today_day - access_day).max(0) as f32
320 })
321 .collect());
322 }
323 Err(AilakeError::Catalog(
324 "last_accessed_at must be Timestamp(Nanosecond/Microsecond) or Utf8".into(),
325 ))
326}
327
328fn apply_decay(batch: &RecordBatch, today_day: i64, lambda: f32) -> AilakeResult<RecordBatch> {
330 let col = batch
331 .column_by_name(LAST_ACCESSED_COL)
332 .ok_or_else(|| AilakeError::Catalog("last_accessed_at column not found".into()))?;
333
334 let days_old = days_old_vec(col, today_day)?;
335 let new_weights: Vec<f32> = days_old.into_iter().map(|d| (-lambda * d).exp()).collect();
336
337 let new_weight_array = Arc::new(Float32Array::from(new_weights));
338
339 let old_schema = batch.schema();
341 let decay_field = Field::new(RECENCY_WEIGHT_COL, DataType::Float32, false);
342
343 let mut new_fields: Vec<arrow_schema::FieldRef> = old_schema.fields().iter().cloned().collect();
344 let mut new_columns: Vec<Arc<dyn Array>> = (0..batch.num_columns())
345 .map(|i| batch.column(i).clone())
346 .collect();
347
348 if let Some(pos) = old_schema
349 .fields()
350 .iter()
351 .position(|f| f.name() == RECENCY_WEIGHT_COL)
352 {
353 new_fields[pos] = Arc::new(decay_field);
354 new_columns[pos] = new_weight_array;
355 } else {
356 new_fields.push(Arc::new(decay_field));
357 new_columns.push(new_weight_array);
358 }
359
360 let new_schema = Arc::new(arrow_schema::Schema::new(new_fields));
361 RecordBatch::try_new(new_schema, new_columns).map_err(|e| AilakeError::Arrow(e.to_string()))
362}
363
364fn parse_iso_date_days(s: &str) -> Option<i64> {
367 if s.len() < 10 {
368 return None;
369 }
370 let y: i64 = s[0..4].parse().ok()?;
371 let m: i64 = s[5..7].parse().ok()?;
372 let d: i64 = s[8..10].parse().ok()?;
373 let a = (14 - m) / 12;
375 let y2 = y + 4800 - a;
376 let m2 = m + 12 * a - 3;
377 let jdn = d + (153 * m2 + 2) / 5 + 365 * y2 + y2 / 4 - y2 / 100 + y2 / 400 - 32045;
378 Some(jdn - 2440588)
380}
381
382fn current_day_since_epoch() -> i64 {
383 SystemTime::now()
384 .duration_since(UNIX_EPOCH)
385 .map(|d| d.as_secs() as i64 / 86400)
386 .unwrap_or(0)
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn parse_iso_date_unix_epoch() {
395 assert_eq!(parse_iso_date_days("1970-01-01T00:00:00"), Some(0));
396 }
397
398 #[test]
399 fn parse_iso_date_known_date() {
400 let days = parse_iso_date_days("2024-01-15").unwrap();
402 assert_eq!(days, 19737);
404 }
405
406 #[test]
407 fn parse_iso_date_returns_none_on_short_string() {
408 assert!(parse_iso_date_days("2024").is_none());
409 assert!(parse_iso_date_days("").is_none());
410 }
411
412 #[test]
413 fn apply_decay_updates_recency_weight() {
414 use arrow_array::StringArray;
415 use arrow_schema::{Field, Schema};
416
417 let today = current_day_since_epoch();
418 let past_day = today - 10;
420 let y = 1970 + past_day / 365; let past_str = "2024-01-05T00:00:00"; let schema = Arc::new(Schema::new(vec![
425 Field::new(LAST_ACCESSED_COL, DataType::Utf8, true),
426 Field::new(RECENCY_WEIGHT_COL, DataType::Float32, false),
427 ]));
428 let batch = RecordBatch::try_new(
429 schema,
430 vec![
431 Arc::new(StringArray::from(vec![past_str])),
432 Arc::new(Float32Array::from(vec![1.0f32])),
433 ],
434 )
435 .unwrap();
436
437 let today_day = 19737i64;
439 let result = apply_decay(&batch, today_day, 0.1).unwrap();
440 let weights = result
441 .column_by_name(RECENCY_WEIGHT_COL)
442 .unwrap()
443 .as_any()
444 .downcast_ref::<Float32Array>()
445 .unwrap();
446
447 let w = weights.value(0);
448 let expected = (-0.1f32 * 10.0).exp();
450 assert!((w - expected).abs() < 0.001, "expected {expected}, got {w}");
451 let _ = y; }
453
454 #[test]
455 fn apply_decay_handles_timestamp_nanosecond() {
456 use arrow_schema::{Field, Schema, TimeUnit};
457
458 let day_19727_ns: i64 = 19727i64 * 86_400 * 1_000_000_000;
461
462 let schema = Arc::new(Schema::new(vec![
463 Field::new(
464 LAST_ACCESSED_COL,
465 DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
466 true,
467 ),
468 Field::new(RECENCY_WEIGHT_COL, DataType::Float32, false),
469 ]));
470 let batch = RecordBatch::try_new(
471 schema,
472 vec![
473 Arc::new(TimestampNanosecondArray::from(vec![day_19727_ns]).with_timezone("UTC")),
474 Arc::new(Float32Array::from(vec![1.0f32])),
475 ],
476 )
477 .unwrap();
478
479 let today_day = 19737i64;
481 let result = apply_decay(&batch, today_day, 0.1).unwrap();
482 let weights = result
483 .column_by_name(RECENCY_WEIGHT_COL)
484 .unwrap()
485 .as_any()
486 .downcast_ref::<Float32Array>()
487 .unwrap();
488 let w = weights.value(0);
489 let expected = (-0.1f32 * 10.0).exp();
490 assert!((w - expected).abs() < 0.001, "expected {expected}, got {w}");
491 }
492
493 #[test]
494 fn now_ns_is_recent() {
495 let floor_2025_ns: i64 = 55 * 365 * 86_400 * 1_000_000_000i64; let t = ailake_core::now_ns();
498 assert!(
499 t > floor_2025_ns,
500 "now_ns() returned suspiciously small value: {t}"
501 );
502 }
503}