Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
a60eb4a
feat(be): cap sessions per identity
sea-snake Aug 22, 2026
34ce16a
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Aug 25, 2026
aadcc99
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Aug 25, 2026
cdc3069
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Aug 25, 2026
16475ba
Merge branch 'feat/session-device-key-proof' into feat/session-create
sea-snake Aug 31, 2026
3a327ae
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Aug 31, 2026
3d0833c
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Aug 31, 2026
ef0316e
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Aug 31, 2026
4bc15ec
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Aug 31, 2026
93beaef
merge: one predicate, and a default idle bound
sea-snake Aug 31, 2026
97ade0b
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 1, 2026
e63f288
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 1, 2026
6a710de
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 1, 2026
25d2b86
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 1, 2026
f3aec2c
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 4, 2026
ba3b00a
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 4, 2026
ebb8ba8
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 4, 2026
2bf6696
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 4, 2026
7211306
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 4, 2026
fc3105e
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 4, 2026
fa25cce
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 4, 2026
d51efa2
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
ac9f482
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
fc072d5
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
e4c3b51
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
e0eff6f
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
92ccdf0
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
127098c
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
67b934f
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
36d7f47
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
dd7c842
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
b4c27c0
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
012070d
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
d8f4bfe
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
b0aabe8
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
ad25a51
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
67c24db
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
d1884e6
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
985c2cb
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
fce16f2
refactor(be): name the account reference list rather than a row
sea-snake Sep 5, 2026
66e464d
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 5, 2026
f0c7c02
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 6, 2026
bea2432
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 6, 2026
9c4520c
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 6, 2026
9e87002
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 6, 2026
db44345
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 6, 2026
26fb487
refactor(be): a browser, not a session device
sea-snake Sep 6, 2026
48fa31a
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 6, 2026
84bba71
test(be): restore two tests a merge silently deleted
sea-snake Sep 6, 2026
dda08dc
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 6, 2026
bb6e9dc
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 6, 2026
b9d663c
fix(be): store the anchor the write path was handed, always
sea-snake Sep 6, 2026
850c996
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 6, 2026
08283a5
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 6, 2026
7649ba0
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
3b93122
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
057f454
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
ad84c7b
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
de03e4b
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
1860f26
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
1c1d44e
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
c051fc3
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
19bd01f
fix(internet_identity): the session cap refuses growth, not every move
sea-snake Sep 7, 2026
d7b64cd
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
4b35ed2
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
c816a6e
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
25f94ef
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 7, 2026
d403a93
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 8, 2026
d3e06c5
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 8, 2026
943d540
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 8, 2026
8ba87e6
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 8, 2026
0b5876b
chore: merge feat/session-create
sea-snake Sep 8, 2026
712c7e1
chore: merge feat/session-create
sea-snake Sep 8, 2026
e845284
chore: merge feat/session-create
sea-snake Sep 8, 2026
93b8c50
chore: merge feat/session-create
sea-snake Sep 8, 2026
e2a39ff
chore: merge feat/session-create
sea-snake Sep 8, 2026
6277d0b
chore: merge feat/session-create
sea-snake Sep 8, 2026
4375096
chore: merge feat/session-create
sea-snake Sep 9, 2026
54fa5f7
chore: merge feat/session-create
sea-snake Sep 9, 2026
bdd4d47
fix(be): the write gate reclaims sessions, and counts them in i64
sea-snake Sep 9, 2026
0e945b1
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 9, 2026
3a3539c
chore: rename the session record accessors at their call sites
sea-snake Sep 9, 2026
a7d0b8d
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 9, 2026
9c6abda
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 9, 2026
246e75e
Merge branch 'feat/session-create' into feat/session-cap-per-identity
sea-snake Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/internet_identity/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,21 @@ const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500;
/// Eviction target, below the cap.
const EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK: u64 = MAX_EVICTABLE_DEFAULT_ACCOUNTS * 9 / 10;

/// Session records one identity may hold, counted as stored rather than as live.
///
/// Counting what is stored is what makes the cap cheap to trigger on: a session expires with
/// no write anywhere, so no counter can follow the live set — something would have to
/// decrement at the moment of expiry, and nothing runs then. An expired record holds its slot
/// until something reclaims it, and because it is the first thing reclaimed, a held slot is
/// never taken from a session in use.
///
/// A bound on concurrent activity, not on history: every session expires within 30 days, so
/// the set is the apps used in the last month times the browsers they were used from.
pub const MAX_SESSIONS_PER_ANCHOR: u32 = 500;
/// Reclaiming goes down to here rather than to the cap, so the pass that walks an identity's
/// rows runs once and then not again for the next fifty sign-ins.
pub const SESSIONS_WATERMARK_PER_ANCHOR: u32 = 450;

/// Bounds one message's eviction work.
const MAX_EVICTIONS_PER_CALL: u64 =
MAX_EVICTABLE_DEFAULT_ACCOUNTS - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK;
Expand Down Expand Up @@ -1855,6 +1870,142 @@ impl<M: Memory + Clone> Storage<M> {
Some(canister_sig_principal(canister_id(), seed.to_vec()))
}

/// Frees a slot for one more session, and reports whether the anchor has one.
///
/// The stored count is a trigger, never the thing the cap is enforced against: a
/// session can expire with no write anywhere, so the count drifts upwards. Once it
/// reaches the cap this recounts what the rows hold and reclaims against that, so an
/// admission is only ever granted against a number that was just counted.
fn ensure_session_slot(
&mut self,
anchor_number: AnchorNumber,
now: Timestamp,
) -> Result<bool, StorageError> {
if self.read(anchor_number)?.session_count < MAX_SESSIONS_PER_ANCHOR {
return Ok(true);
}
Ok(self.reclaim_sessions(anchor_number, now)? < MAX_SESSIONS_PER_ANCHOR)
}

/// Replaces the count with a number that was counted rather than accumulated.
fn set_session_count(
&mut self,
anchor_number: AnchorNumber,
count: u32,
) -> Result<(), StorageError> {
let mut anchor = self.read(anchor_number)?;
if anchor.session_count == count {
return Ok(());
}
anchor.session_count = count;
self.write(anchor)
}

/// Walks the anchor's rows once and reclaims down to the watermark, taking sessions in
/// [`SessionRecord::reclaim_order`]: dead ones first, then the least recently used.
///
/// Returns what the rows actually hold once it is done, which is the number the cap is
/// enforced against. The stored counter is only ever a trigger for running this pass —
/// it can drift, this cannot, because it counts the sessions themselves.
///
/// One pass per fifty sign-ins, because it reclaims to the watermark rather than to the
/// cap, and bounded by the same row limit account eviction uses.
fn reclaim_sessions(
&mut self,
anchor_number: AnchorNumber,
now: Timestamp,
) -> Result<u32, StorageError> {
struct Candidate {
order: (bool, Timestamp, SessionDeviceId),
row: usize,
account_number: Option<AccountNumber>,
device_id: SessionDeviceId,
}

// Every row, not a bounded prefix of them: the number this returns is what the cap is
// enforced against, and a truncated scan would undercount, lower the counter to the
// undercount, and let the stored set climb past the cap from there. An identity's rows
// are already bounded — the row cap holds the evictable ones and the account cap holds
// the rest — and a sequential scan of them costs a fraction of the writes it saves.
let mut rows: Vec<(ApplicationNumber, Vec<AccountReference>)> = self
.stable_account_reference_list_memory
.range(
(anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX),
)
.map(|((_, application_number), list)| (application_number, list.into()))
.collect();

let mut candidates: Vec<Candidate> = vec![];
for (row, (_, references)) in rows.iter().enumerate() {
for reference in references {
for session in &reference.sessions {
candidates.push(Candidate {
order: session.reclaim_order(now),
row,
account_number: reference.account_number,
device_id: session.device_id,
});
}
}
}
let stored = candidates.len() as u32;
candidates.sort_by_key(|candidate| candidate.order);

let surplus = stored.saturating_sub(SESSIONS_WATERMARK_PER_ANCHOR) as usize;
let victims = &candidates[..surplus.min(candidates.len())];
if victims.is_empty() {
self.set_session_count(anchor_number, stored)?;
return Ok(stored);
}

// One write per row rather than one per victim: the row is a single blob, so
// dropping several of its sessions one at a time would rewrite it several times.
let mut touched: Vec<usize> = victims.iter().map(|victim| victim.row).collect();
touched.sort_unstable();
touched.dedup();

let mut dropped_total = 0usize;
for row in touched {
let (application_number, references) = &mut rows[row];
let application_number = *application_number;
let mut removed: Vec<(Option<AccountNumber>, SessionRecord)> = vec![];
for reference in references.iter_mut() {
let account_number = reference.account_number;
reference.sessions.retain(|session| {
// The row has to be part of the match: one browser holds one session per
// account, but the same browser and the same account number appear in
// every row, so matching on that pair alone reaches across applications.
let doomed = victims.iter().any(|victim| {
victim.row == row
&& victim.account_number == account_number
&& victim.device_id == session.device_id
});
if doomed {
removed.push((account_number, session.clone()));
}
!doomed
});
}
if removed.is_empty() {
continue;
}
self.write_reference_list(anchor_number, application_number, references.clone())?;
for (account_number, session) in &removed {
self.unindex_sessions(
anchor_number,
application_number,
*account_number,
std::slice::from_ref(session),
);
}
dropped_total += removed.len();
}

let remaining = stored.saturating_sub(dropped_total as u32);
self.set_session_count(anchor_number, remaining)?;
Ok(remaining)
}

/// Moves the count without considering the cap, for the paths that only remove.
fn change_session_count(
&mut self,
Expand Down Expand Up @@ -2001,6 +2152,12 @@ impl<M: Memory + Clone> Storage<M> {
}
};

// Reclaiming before the session is admitted rather than after it: the stored set
// never sits above the cap, not even for the rest of this message.
if !self.ensure_session_slot(anchor_number, now_ns)? {
Comment thread
sea-snake marked this conversation as resolved.
Outdated
return Err(StorageError::SessionCapNotReclaimed { anchor_number });
}

let mut references: Vec<AccountReference> = self
.lookup_account_references(anchor_number, application_number)
.ok_or(StorageError::MissingAccount {
Expand Down
Loading
Loading