mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: delete a browser's copy of an AI session past its workspace retention (#11156)
* feat: delete a browser's copy of an AI session past its workspace retention Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: tell the AI session retention only to a member who can reach the workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: keep the retention sweep's design narrative in the docs, not the code * fix: give the session retention its own route, leaving the status contract alone Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: shorten the retention route comment to its constraints * docs: name the two clocks in the retention setting, and the deploy window --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ee6d317e31
commit
a48ae656ae
+79
-25
@@ -240,32 +240,86 @@ the first push after it or on the next page load, whichever comes first.
|
||||
|
||||
`ai_config.sessions_retention_days` (per workspace, in the AI settings; unset by default;
|
||||
the `sessions_storage_disabled` pattern: no migration, carried by settings export and the
|
||||
CLI; 1 to 3650) puts an age on backups, counted from the last push of the session that
|
||||
completed. It applies to the backup only: a browser keeps its copy whatever the retention,
|
||||
and a backup swept while a browser still has the session comes back once that browser writes
|
||||
to it again (its incremental push is refused and goes whole).
|
||||
CLI; 1 to 3650) puts an age on sessions, counted from their last activity. Each side applies
|
||||
it with its own clock against its own timestamps, so no clock is compared with another
|
||||
machine's, and the two do not time the same event: the server counts the last push that
|
||||
completed, a browser its last local activity, which includes reading new messages and is not
|
||||
pushed. A backup swept while a browser still reads its copy comes back once that browser
|
||||
writes to the session again (its incremental push is refused and goes whole):
|
||||
|
||||
The server sweeps the object store (`sweep_expired_ai_session_backups`, from the monitor about
|
||||
every 40 minutes on each server, one pass at a time under a session-level advisory lock). For
|
||||
every workspace with a retention it takes the store its backups live in, its own storage or
|
||||
the instance store standing in, decided from the row it reads the generation from as the
|
||||
routes do, names the users under the generation prefix (`list_with_delimiter`) and lists each
|
||||
user's `index/` once: one object per session, nothing of what the sessions hold. A session
|
||||
whose marker is older than the retention is removed under its lock (`lock_session`), once its
|
||||
markers, listed again there, are still all older: a push that renewed the session between the
|
||||
walk and the lock keeps it, and one split over parts either holds the lock or has the session
|
||||
unlisted with its token next to the markers (`index/{sid}/push`), which the sweep leaves
|
||||
alone while the token is younger than the retention: an older one is a push a browser
|
||||
abandoned, whose landed parts nothing lists, and it goes the same way. Before deleting
|
||||
anything the sweep writes a record next to the markers (`index/{sid}/sweep`, not an epoch, so
|
||||
neither `list` nor `pull` counts it), and `remove_session` deletes it last: a removal cut
|
||||
short, its markers already gone, is found by the next pass and finished, unless a push listed
|
||||
the session again first. At most 1000 sessions per workspace and pass; the rest wait for the
|
||||
next. `list` leaves an expired marker out of its answer meanwhile, so a browser never restores
|
||||
a session the sweep has not reached. The marker's modification time is the storage's clock and
|
||||
the cutoff the server's. The sweep reaches only the backups the routes would: a deleted
|
||||
workspace's stay in its storage, and so do those a workspace keeps in the instance store once
|
||||
`ai_sessions_instance_storage_fallback` is set to false.
|
||||
- The server sweeps the object store (`sweep_expired_ai_session_backups`, from the monitor
|
||||
about every 40 minutes on each server, one pass at a time under a session-level advisory
|
||||
lock). For every workspace with a retention it takes the store its backups live in, its
|
||||
own storage or the instance store standing in, decided from the row it reads the
|
||||
generation from as the routes do, names the users under the generation prefix
|
||||
(`list_with_delimiter`) and lists each user's `index/` once: one object
|
||||
per session, nothing of what the sessions hold. A session whose marker is older than the
|
||||
retention is removed under its lock (`lock_session`), once its markers, listed again
|
||||
there, are still all older: a push that renewed the session between the walk and the lock
|
||||
keeps it, and one split over parts either holds the lock or has the session unlisted with
|
||||
its token next to the markers (`index/{sid}/push`), which the sweep leaves alone while the
|
||||
token is younger than the retention: an older one is a push a browser abandoned, whose
|
||||
landed parts nothing lists, and it goes the same way. Before deleting anything the sweep
|
||||
writes a record next to
|
||||
the markers (`index/{sid}/sweep`, not an epoch, so neither `list` nor `pull` counts it),
|
||||
and `remove_session` deletes it last: a removal cut short, its markers already gone, is
|
||||
found by the next pass and finished, unless a push listed the session again first. At
|
||||
most 1000 sessions per workspace and pass; the rest wait for the next. `list` leaves an
|
||||
expired marker out of its answer meanwhile, so a browser never restores a session the
|
||||
sweep has not reached. The marker's modification time is the storage's clock and the
|
||||
cutoff the server's. The sweep reaches only the backups the routes would: a deleted
|
||||
workspace's stay in its storage, and so do those a workspace keeps in the instance store
|
||||
once `ai_sessions_instance_storage_fallback` is set to false.
|
||||
- The browser sweeps its own stores when a tab resolves the logged-in user
|
||||
(`sweepExpiredSessions`, from the one `onUserChange` in `sessionState.svelte.ts`), before
|
||||
that tab reads a single session. A session whose last activity is older than the retention
|
||||
by the browser's clock is deleted locally, record, chats, images, attached files and
|
||||
artifacts. A restored session carries the backup's time as its last activity, the storage's
|
||||
clock, so it counts from the later of that and the moment it was restored here
|
||||
(`restoredAt`): a browser clock ahead of the storage's never deletes a session it just
|
||||
brought back. Archived sessions count like any other, and persisted unsent drafts by their
|
||||
pending workspace.
|
||||
|
||||
The stores are shared by the user's tabs, and each keeps copies of the sessions in memory,
|
||||
so every tab holds a shared Web Lock from before it reads them until it stops using them,
|
||||
and the sweep deletes only while holding that lock exclusively, requested if available:
|
||||
granted exactly when no tab of the user has the sessions loaded, which is why the sweep
|
||||
runs where it does and nowhere else. Nothing holds a copy of what it deletes and nothing
|
||||
writes the stores meanwhile, so it deletes one record at a time and without re-reading. It
|
||||
also takes the tab lock the flush and the restore take, again only if available, so neither
|
||||
plans nor stages a session half deleted; like the restore, it does not run where Web Locks
|
||||
do not exist. With several tabs open nothing is swept, until one of them reloads alone.
|
||||
|
||||
The hold is only as good as the tabs that take it, so a tab still running a build from before
|
||||
it has the sessions loaded and holds nothing. A tab loaded after that one, across a deploy,
|
||||
can sweep a session the older tab has in memory, and a write there afterwards brings the
|
||||
record back without its chats, which the next flush pushes. It needs a tab left open across a
|
||||
deploy, a session untouched for the whole retention, and the user going back to that session
|
||||
in the older tab; the next sweep deletes it again. The same window is open to the
|
||||
workspace-lifecycle delete in `reconcileSessionsLifecycle`, which no lock guards at all.
|
||||
|
||||
What deletes is the retention the server gives as the sweep runs, asked for under both locks
|
||||
(`POST /workspaces/session_workspace_retention`, its own route rather than a field on the
|
||||
lifecycle status, whose answer a tab loaded before this version still reads). Never a
|
||||
remembered one: a retention raised or cleared since would otherwise delete a session that is
|
||||
now within it, and a persisted unsent draft has no backup to come back from. What the sweep
|
||||
keeps in localStorage decides only whether to ask again — it asks when it has asked nothing
|
||||
yet, when the answer it has is a day old, or when that answer marks a session expired — so
|
||||
an ordinary load costs no request at all. An answer that does not arrive within five seconds
|
||||
leaves the sessions for the next load rather than delete on what this browser guessed. That
|
||||
route answers for a workspace the caller can be authed into, unlike the status: a status is
|
||||
what to do with the caller's own sessions, a setting is the workspace's to tell, so a
|
||||
disabled membership is told nothing though its sessions still reconcile.
|
||||
|
||||
Each session's record goes before its pieces, so nothing plans a push for it afterwards,
|
||||
and a localStorage key written before the record and removed once every piece is gone makes
|
||||
a later sweep finish a deletion that failed, unless a restore brought the session back
|
||||
since. The record is deleted without the tombstone a user delete leaves, which is what lets
|
||||
a restore bring it back. The session's dirty mark and sync row go with it (`sessionSwept`),
|
||||
unless the row still carries a removal or a restore's staging. Nothing is sent to the storage: the local
|
||||
copy's age says nothing about another device's, which may have pushed the session since,
|
||||
and the server applies the rule to the backup on its own. A session swept here that the
|
||||
storage still lists comes back on the next restore.
|
||||
|
||||
## Limits
|
||||
|
||||
|
||||
Reference in New Issue
Block a user