Skip to content

Commit 996f44f

Browse files
authored
enhance(conversation): improve expired conversation cleanup (#360)
Cleanup of expired ephemeral conversations is now automatically triggered during workspace persistence. This ensures that non-active expired conversations are always cleaned up, even if the active conversation has not changed. The cleanup process has been optimized in the `jp_storage` crate by: - Using parallel iteration to scan conversation directories. - Implementing a specialized metadata parser that only extracts the `expires_at` field, avoiding full JSON deserialization of conversation metadata. - Expanding the cleanup to cover both local workspace and user-global storage roots. The `remove_non_active_ephemeral_conversations` method has been removed from `Workspace` in favor of integrating the logic directly into the `persist` flow. Signed-off-by: Jean Mertz <[email protected]>
1 parent cd36398 commit 996f44f

File tree

3 files changed

+66
-28
lines changed

3 files changed

+66
-28
lines changed

crates/jp_cli/src/cmd/query.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,6 @@ impl Query {
520520
);
521521

522522
ws.set_active_conversation_id(id)?;
523-
ws.remove_non_active_ephemeral_conversations();
524523
}
525524

526525
Ok(last_active_conversation_id)

crates/jp_storage/src/lib.rs

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -373,34 +373,49 @@ impl Storage {
373373

374374
/// Remove all ephemeral conversations, except the active one.
375375
pub fn remove_ephemeral_conversations(&self, skip: &[ConversationId]) {
376-
for (id, conversation) in self.load_all_conversations_details() {
377-
if conversation
378-
.expires_at
379-
.is_none_or(|v| v > UtcDateTime::now())
380-
|| skip.contains(&id)
381-
{
376+
for root in [Some(&self.root), self.user.as_ref()] {
377+
let Some(root) = root else {
382378
continue;
383-
}
384-
385-
let path = self
386-
.root
387-
.join(CONVERSATIONS_DIR)
388-
.join(id.to_dirname(conversation.title.as_deref()));
379+
};
389380

390-
if let Err(error) = fs::remove_dir_all(&path) {
391-
warn!(path = %path.display(), %error, "Failed to remove ephemeral conversation.");
392-
}
381+
let path = root.join(CONVERSATIONS_DIR);
382+
dir_entries(&path)
383+
.collect::<Vec<_>>()
384+
.into_par_iter()
385+
.filter_map(|entry| {
386+
let id = load_conversation_id_from_entry(&entry)?;
387+
if skip.contains(&id) {
388+
return None;
389+
}
390+
391+
let path = entry.path();
392+
let expiring_ts = get_expiring_timestamp(&path)?;
393+
if expiring_ts > UtcDateTime::now() {
394+
return None;
395+
}
396+
397+
Some(path)
398+
})
399+
.for_each(|path| {
400+
if let Err(error) = fs::remove_dir_all(&path) {
401+
warn!(
402+
path = path.display().to_string(),
403+
error = error.to_string(),
404+
"Failed to remove ephemeral conversation."
405+
);
406+
}
407+
});
393408
}
394409
}
395410
}
396411

397-
fn load_count_and_timestamp_events(path: &Path) -> Option<(usize, Option<UtcDateTime>)> {
412+
fn load_count_and_timestamp_events(root: &Path) -> Option<(usize, Option<UtcDateTime>)> {
398413
#[derive(serde::Deserialize)]
399414
struct RawEvent {
400415
timestamp: Box<serde_json::value::RawValue>,
401416
}
402417
let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second].[subsecond]");
403-
let path = path.join(EVENTS_FILE);
418+
let path = root.join(EVENTS_FILE);
404419
let file = fs::File::open(&path).ok()?;
405420
let reader = BufReader::new(file);
406421

@@ -425,6 +440,38 @@ fn load_count_and_timestamp_events(path: &Path) -> Option<(usize, Option<UtcDate
425440
Some((event_count, last_timestamp))
426441
}
427442

443+
/// Get the `expires_at` timestamp from the conversation metadata file, if the
444+
/// file exists, and the `expires_at` timestamp is set.
445+
///
446+
/// This is a specialized function that ONLY parses the `expires_at` field in
447+
/// the JSON metadata file, for performance reasons.
448+
fn get_expiring_timestamp(root: &Path) -> Option<UtcDateTime> {
449+
#[derive(serde::Deserialize)]
450+
struct RawConversation {
451+
expires_at: Option<Box<serde_json::value::RawValue>>,
452+
}
453+
let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second].[subsecond]");
454+
let path = root.join(METADATA_FILE);
455+
let file = fs::File::open(&path).ok()?;
456+
let reader = BufReader::new(file);
457+
458+
let conversation: RawConversation = match serde_json::from_reader(reader) {
459+
Ok(conversation) => conversation,
460+
Err(error) => {
461+
warn!(%error, path = %path.display(), "Error parsing JSON metadata file.");
462+
return None;
463+
}
464+
};
465+
466+
let ts = conversation.expires_at?;
467+
let ts = ts.get();
468+
if ts.len() < 2 || !ts.starts_with('"') || !ts.ends_with('"') {
469+
return None;
470+
}
471+
472+
UtcDateTime::parse(&ts[1..ts.len() - 1], &fmt).ok()
473+
}
474+
428475
fn dir_entries(path: &Path) -> impl Iterator<Item = fs::DirEntry> {
429476
fs::read_dir(path)
430477
.into_iter()

crates/jp_workspace/src/lib.rs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ impl Workspace {
228228

229229
trace!("Persisting state.");
230230

231+
let active_id = self.active_conversation_id();
231232
let storage = self.storage.as_mut().ok_or(Error::MissingStorage)?;
232233

233234
storage.persist_conversations_metadata(&self.state.user.conversations_metadata)?;
@@ -241,6 +242,7 @@ impl Workspace {
241242
.active_conversation_id,
242243
&self.state.local.active_conversation,
243244
)?;
245+
storage.remove_ephemeral_conversations(&[active_id]);
244246

245247
info!(path = %self.root.display(), "Persisted state.");
246248
Ok(())
@@ -509,16 +511,6 @@ impl Workspace {
509511
pub fn id(&self) -> &Id {
510512
&self.id
511513
}
512-
513-
/// Remove all ephemeral conversations, except the active one.
514-
pub fn remove_non_active_ephemeral_conversations(&self) {
515-
let Some(storage) = self.storage.as_ref() else {
516-
return;
517-
};
518-
519-
let active_id = self.active_conversation_id();
520-
storage.remove_ephemeral_conversations(&[active_id]);
521-
}
522514
}
523515

524516
fn get_or_init_events<'a>(

0 commit comments

Comments
 (0)