Skip to content

Commit ddced2c

Browse files
sanityclaude
andauthored
feat(hosted): inactive-user TTL reclaim (#4561 P5) (#4577)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d694b79 commit ddced2c

7 files changed

Lines changed: 1736 additions & 3 deletions

File tree

crates/core/src/client_events/websocket.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,8 +1089,13 @@ async fn websocket_commands(
10891089
// that carries a `UserSecretContext`, which only the hosted serve path
10901090
// (which DOES install the limiter) ever derives.
10911091
op_rate_limiter: Option<Extension<UserOpRateLimiter>>,
1092+
// The node's secrets dir for stamping per-user last-activity markers
1093+
// (#4561). OPTIONAL: only `serve_client_api_in_impl` installs it; the
1094+
// standalone test paths install nothing, so absence ⇒ no activity stamping.
1095+
activity_secrets_dir: Option<Extension<crate::server::ActivitySecretsDir>>,
10921096
) -> Response {
10931097
let op_rate_limiter = op_rate_limiter.map(|Extension(l)| l);
1098+
let activity_secrets_dir = activity_secrets_dir.map(|Extension(d)| d);
10941099
let on_upgrade = move |ws: WebSocket| async move {
10951100
// Get the data we need from the DashMap
10961101
// Track whether a token was provided but is invalid (stale/expired)
@@ -1149,6 +1154,7 @@ async fn websocket_commands(
11491154
auth_and_instance,
11501155
user_context,
11511156
op_rate_limiter,
1157+
activity_secrets_dir,
11521158
token_is_invalid,
11531159
encoding_protoc,
11541160
api_version,
@@ -1212,6 +1218,10 @@ async fn websocket_interface(
12121218
// user's connections; consulted in `process_client_request` only when this
12131219
// connection carries a `user_context`.
12141220
op_rate_limiter: Option<UserOpRateLimiter>,
1221+
// The node's secrets dir for per-user last-activity stamping (#4561). `None`
1222+
// on standalone/test paths; an empty path also disables stamping. Used only
1223+
// when this connection carries a `user_context` (hosted mode).
1224+
activity_secrets_dir: Option<crate::server::ActivitySecretsDir>,
12151225
token_is_invalid: bool,
12161226
encoding_protoc: EncodingProtocol,
12171227
api_version: ApiVersion,
@@ -1220,6 +1230,16 @@ async fn websocket_interface(
12201230
let (mut response_rx, client_id) =
12211231
new_client_connection(&request_sender, auth_token.clone()).await?;
12221232
let (mut server_sink, mut client_stream) = ws.split();
1233+
1234+
// Stamp last-activity on CONNECT (#4561, inactive-user TTL): a fresh
1235+
// connection from a hosted user is activity, so refresh their `.last_seen`
1236+
// marker right away (debounced — a no-op if a recent stamp exists). Gated on
1237+
// `user_context.is_some()` so Local/non-hosted connections never write a
1238+
// marker. Best-effort and off the request hot path (runs once per
1239+
// connection). See `stamp_activity` for the wall-clock + debounce details.
1240+
if let (Some(ctx), Some(dir)) = (user_context.as_ref(), activity_secrets_dir.as_ref()) {
1241+
stamp_activity(dir, ctx);
1242+
}
12231243
let contract_updates: Arc<Mutex<VecDeque<(_, mpsc::Receiver<HostResult>)>>> =
12241244
Arc::new(Mutex::new(VecDeque::new()));
12251245

@@ -1336,6 +1356,7 @@ async fn websocket_interface(
13361356
auth_token.as_mut().map(|t| t.1),
13371357
user_context.as_ref(),
13381358
op_rate_limiter.as_ref(),
1359+
activity_secrets_dir.as_ref(),
13391360
api_version,
13401361
&mut delegate_rate_limiter,
13411362
&mut conn_state,
@@ -1614,6 +1635,28 @@ fn extract_stream_content(
16141635
/// (control / reassembly, already bounded per-connection by the stdlib) — does
16151636
/// not count, so a client can always authenticate and disconnect even while
16161637
/// throttled.
1638+
/// Stamp a hosted user's durable last-activity marker (#4561, P5 of #4381,
1639+
/// inactive-user TTL). Wraps [`crate::wasm_runtime::stamp_user_last_seen`] with
1640+
/// the real wall-clock now and the default debounce, and short-circuits when no
1641+
/// secrets dir is configured (empty path ⇒ standalone/test composition with no
1642+
/// tree to mark). Cheap on the hot path: a single `stat`, and a write only when
1643+
/// the marker is older than the debounce interval.
1644+
fn stamp_activity(dir: &crate::server::ActivitySecretsDir, ctx: &UserSecretContext) {
1645+
let base = dir.0.as_ref();
1646+
if base.as_os_str().is_empty() {
1647+
return;
1648+
}
1649+
// Single shared wall-clock source (incl. its clamp-to-0) so the stamp hook
1650+
// and the reclaim sweep can never drift.
1651+
let now = crate::wasm_runtime::wall_clock_unix_secs();
1652+
crate::wasm_runtime::stamp_user_last_seen(
1653+
base,
1654+
ctx.user_id(),
1655+
now,
1656+
crate::wasm_runtime::DEFAULT_LAST_SEEN_DEBOUNCE_SECS,
1657+
);
1658+
}
1659+
16171660
fn is_rate_limited_op(req: &ClientRequest<'_>) -> bool {
16181661
matches!(
16191662
req,
@@ -1630,6 +1673,7 @@ async fn process_client_request(
16301673
origin_contract: Option<ContractInstanceId>,
16311674
user_context: Option<&UserSecretContext>,
16321675
op_rate_limiter: Option<&UserOpRateLimiter>,
1676+
activity_secrets_dir: Option<&crate::server::ActivitySecretsDir>,
16331677
api_version: ApiVersion,
16341678
rate_limiter: &mut DelegateRateLimiter,
16351679
conn_state: &mut ConnectionState,
@@ -1766,6 +1810,18 @@ async fn process_client_request(
17661810
}
17671811
}
17681812

1813+
// Stamp last-activity for this hosted user on EVERY request (#4561,
1814+
// inactive-user TTL). Done BEFORE the rate-limit check below so a user who
1815+
// is currently being THROTTLED still counts as active and can never be
1816+
// reclaimed as "abandoned" — a rate-limited request is the strongest
1817+
// possible evidence the user is present. Gated on `user_context.is_some()`
1818+
// (hosted mode honored a token), and debounced to ~free on the hot path
1819+
// (a single `stat`, no write, unless the marker is >1h stale). See
1820+
// `stamp_activity`.
1821+
if let (Some(ctx), Some(dir)) = (user_context, activity_secrets_dir) {
1822+
stamp_activity(dir, ctx);
1823+
}
1824+
17691825
// Per-user operation rate limit (#4561, P5 of #4381). Bounds how fast a
17701826
// single HOSTED user can issue WORK-CAUSING operations so one visitor cannot
17711827
// flood the node's executor/network. See `is_rate_limited_op` for exactly
@@ -3817,6 +3873,7 @@ mod tests {
38173873
None,
38183874
user_context,
38193875
op_rate_limiter,
3876+
None, // no activity stamping in rate-limit unit tests
38203877
ApiVersion::V1,
38213878
&mut delegate_rate_limiter,
38223879
&mut conn_state,

crates/core/src/config.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,28 @@ pub struct ConfigArgs {
134134
#[arg(long = "per-user-secret-quota", env = "PER_USER_SECRET_QUOTA")]
135135
pub per_user_secret_quota_bytes: Option<u64>,
136136

137+
/// Inactivity TTL, in seconds, after which a HOSTED user's entire
138+
/// per-user data is reclaimed by a background sweep (#4561, P5 of #4381).
139+
/// Keeps a public "try Freenet" node a transient demo with bounded storage:
140+
/// a visitor who walks away has their namespace reclaimed after this many
141+
/// real-calendar seconds of inactivity (durable across restarts). Default:
142+
/// 2_592_000 (30 days). `0` disables the sweep entirely. Has NO effect
143+
/// outside hosted mode — Local single-user data is never enumerated or
144+
/// reclaimed (it lives outside the `users/<id>/` tree the sweep touches).
145+
#[arg(long = "per-user-inactive-ttl", env = "PER_USER_INACTIVE_TTL")]
146+
pub per_user_inactive_ttl_secs: Option<u64>,
147+
148+
/// How often, in seconds, the inactive-user reclaim sweep runs (#4561).
149+
/// Only relevant when hosted mode is on and `per-user-inactive-ttl` is
150+
/// non-zero. Default: 3_600 (hourly) — far finer than the 30-day TTL, so
151+
/// reclamation lag is negligible while keeping the sweep's disk-walk cost
152+
/// trivial. Must be > 0; `0` is treated as the default.
153+
#[arg(
154+
long = "inactive-user-sweep-interval",
155+
env = "INACTIVE_USER_SWEEP_INTERVAL"
156+
)]
157+
pub inactive_user_sweep_interval_secs: Option<u64>,
158+
137159
/// Byte budget for the compiled-WASM **contract** module cache. The
138160
/// **delegate** cache gets a fraction of this value
139161
/// (`DELEGATE_MODULE_CACHE_BUDGET_DIVISOR`, currently 1/4), so the combined
@@ -206,6 +228,8 @@ impl Default for ConfigArgs {
206228
max_blocking_threads: None,
207229
max_hosting_storage: None,
208230
per_user_secret_quota_bytes: None,
231+
per_user_inactive_ttl_secs: None,
232+
inactive_user_sweep_interval_secs: None,
209233
module_cache_budget_bytes: None,
210234
shutdown_drain_secs: None,
211235
telemetry: Default::default(),
@@ -461,6 +485,10 @@ impl ConfigArgs {
461485
.get_or_insert(cfg.max_hosting_storage);
462486
self.per_user_secret_quota_bytes
463487
.get_or_insert(cfg.per_user_secret_quota_bytes);
488+
self.per_user_inactive_ttl_secs
489+
.get_or_insert(cfg.per_user_inactive_ttl_secs);
490+
self.inactive_user_sweep_interval_secs
491+
.get_or_insert(cfg.inactive_user_sweep_interval_secs);
464492
self.module_cache_budget_bytes
465493
.get_or_insert(cfg.module_cache_budget_bytes);
466494
self.shutdown_drain_secs
@@ -899,6 +927,9 @@ impl ConfigArgs {
899927
.ws_api
900928
.per_user_export_min_interval_secs
901929
.unwrap_or_else(default_per_user_export_min_interval_secs),
930+
// Runtime-only: resolve the secrets dir for this mode so the WS
931+
// serve layer can stamp per-user activity markers (#4561).
932+
secrets_dir: config_paths.secrets_dir(mode),
902933
},
903934
secrets,
904935
log_level: self.log_level.unwrap_or(tracing::log::LevelFilter::Info),
@@ -915,6 +946,23 @@ impl ConfigArgs {
915946
per_user_secret_quota_bytes: self
916947
.per_user_secret_quota_bytes
917948
.unwrap_or(crate::wasm_runtime::DEFAULT_PER_USER_SECRET_QUOTA_BYTES as u64),
949+
per_user_inactive_ttl_secs: self
950+
.per_user_inactive_ttl_secs
951+
.unwrap_or(default_per_user_inactive_ttl_secs()),
952+
inactive_user_sweep_interval_secs: {
953+
// `0` means "use the default" (an interval of 0 is meaningless —
954+
// the sweep would otherwise floor it to 1s and hammer the disk).
955+
// Remap here so the resolved value always reflects the documented
956+
// semantics, rather than relying on a downstream `.max(1)`.
957+
let v = self
958+
.inactive_user_sweep_interval_secs
959+
.unwrap_or(default_inactive_user_sweep_interval_secs());
960+
if v == 0 {
961+
default_inactive_user_sweep_interval_secs()
962+
} else {
963+
v
964+
}
965+
},
918966
module_cache_budget_bytes: self
919967
.module_cache_budget_bytes
920968
.unwrap_or_else(crate::wasm_runtime::default_module_cache_budget_bytes),
@@ -1054,6 +1102,23 @@ pub struct Config {
10541102
rename = "per-user-secret-quota"
10551103
)]
10561104
pub per_user_secret_quota_bytes: u64,
1105+
/// Inactivity TTL in seconds after which a HOSTED user's entire per-user
1106+
/// data is reclaimed by a background sweep (#4561, P5 of #4381). Durable,
1107+
/// real-calendar time (survives restarts). Default 2_592_000 (30 days);
1108+
/// `0` disables the sweep. No effect outside hosted mode — Local
1109+
/// single-user data is never enumerated or reclaimed.
1110+
#[serde(
1111+
default = "default_per_user_inactive_ttl_secs",
1112+
rename = "per-user-inactive-ttl"
1113+
)]
1114+
pub per_user_inactive_ttl_secs: u64,
1115+
/// How often (seconds) the inactive-user reclaim sweep runs (#4561). Only
1116+
/// relevant in hosted mode with a non-zero TTL. Default 3_600 (hourly).
1117+
#[serde(
1118+
default = "default_inactive_user_sweep_interval_secs",
1119+
rename = "inactive-user-sweep-interval"
1120+
)]
1121+
pub inactive_user_sweep_interval_secs: u64,
10571122
/// Byte budget for the compiled-WASM **contract** module cache. The
10581123
/// delegate cache gets a fraction of this
10591124
/// (`DELEGATE_MODULE_CACHE_BUDGET_DIVISOR`), so the combined ceiling is
@@ -1124,6 +1189,21 @@ fn default_per_user_secret_quota_bytes() -> u64 {
11241189
crate::wasm_runtime::DEFAULT_PER_USER_SECRET_QUOTA_BYTES as u64
11251190
}
11261191

1192+
/// Default inactive-user TTL (30 days). Resolves to
1193+
/// [`crate::wasm_runtime::DEFAULT_PER_USER_INACTIVE_TTL_SECS`], the single
1194+
/// source of truth, so the operator-facing default and the sweep's fallback
1195+
/// never drift.
1196+
const fn default_per_user_inactive_ttl_secs() -> u64 {
1197+
crate::wasm_runtime::DEFAULT_PER_USER_INACTIVE_TTL_SECS
1198+
}
1199+
1200+
/// Default inactive-user sweep interval (1 hour). Far finer than the 30-day
1201+
/// TTL, so reclamation lag is negligible while the periodic disk walk stays
1202+
/// cheap.
1203+
const fn default_inactive_user_sweep_interval_secs() -> u64 {
1204+
3_600
1205+
}
1206+
11271207
/// Default contract-module cache byte budget, scaled to system RAM
11281208
/// (`clamp(total_ram / 8, 64 MiB, 1.5 GiB)`).
11291209
///
@@ -2022,6 +2102,21 @@ pub struct WebsocketApiConfig {
20222102
rename = "per-user-export-min-interval-secs"
20232103
)]
20242104
pub per_user_export_min_interval_secs: u64,
2105+
2106+
/// Resolved secrets directory for this node. RUNTIME-ONLY, NOT persisted
2107+
/// (`#[serde(skip)]`, like `TelemetryConfig::is_test_environment`): it is
2108+
/// derived from the full `Config` in `build()` (`config.secrets_dir()`), so
2109+
/// serializing/round-tripping a `WebsocketApiConfig` standalone leaves it
2110+
/// empty and `build()` repopulates it.
2111+
///
2112+
/// The WS serve layer injects it as an `Extension` so the per-user
2113+
/// last-activity marker (#4561, P5 of #4381, inactive-user TTL) can be
2114+
/// stamped at the same `<base>/users/<user_id>/.last_seen` location the
2115+
/// reclaim sweep reads. Empty (the default on the standalone test paths)
2116+
/// disables stamping, which is correct for non-hosted/test composition that
2117+
/// has no secrets tree to mark.
2118+
#[serde(skip)]
2119+
pub secrets_dir: std::path::PathBuf,
20252120
}
20262121

20272122
#[inline]
@@ -2067,6 +2162,7 @@ impl From<SocketAddr> for WebsocketApiConfig {
20672162
per_user_op_rate_limit: default_per_user_op_rate_limit(),
20682163
per_user_op_burst: default_per_user_op_burst(),
20692164
per_user_export_min_interval_secs: default_per_user_export_min_interval_secs(),
2165+
secrets_dir: std::path::PathBuf::new(),
20702166
}
20712167
}
20722168
}
@@ -2085,6 +2181,7 @@ impl Default for WebsocketApiConfig {
20852181
per_user_op_rate_limit: default_per_user_op_rate_limit(),
20862182
per_user_op_burst: default_per_user_op_burst(),
20872183
per_user_export_min_interval_secs: default_per_user_export_min_interval_secs(),
2184+
secrets_dir: std::path::PathBuf::new(),
20882185
}
20892186
}
20902187
}
@@ -3986,6 +4083,8 @@ mod tests {
39864083
max_blocking_threads: None,
39874084
max_hosting_storage: None,
39884085
per_user_secret_quota_bytes: None,
4086+
per_user_inactive_ttl_secs: None,
4087+
inactive_user_sweep_interval_secs: None,
39894088
module_cache_budget_bytes: None,
39904089
shutdown_drain_secs: None,
39914090
telemetry: Default::default(),
@@ -4044,6 +4143,9 @@ mod tests {
40444143
per_user_op_rate_limit: 33,
40454144
per_user_op_burst: 77,
40464145
per_user_export_min_interval_secs: 17,
4146+
// serde-skip runtime field; repopulated by build() and not
4147+
// asserted in the round-trip (bound to `_` in the destructure).
4148+
secrets_dir: std::path::PathBuf::new(),
40474149
},
40484150
secrets: base.secrets.clone(),
40494151
log_level: tracing::log::LevelFilter::Debug,
@@ -4055,6 +4157,8 @@ mod tests {
40554157
max_blocking_threads: 7,
40564158
max_hosting_storage: 123_456_789,
40574159
per_user_secret_quota_bytes: 7_654_321,
4160+
per_user_inactive_ttl_secs: 1_234_567,
4161+
inactive_user_sweep_interval_secs: 7_200,
40584162
module_cache_budget_bytes: 987_654_321,
40594163
telemetry: TelemetryConfig {
40604164
enabled: false,
@@ -4090,6 +4194,8 @@ mod tests {
40904194
max_blocking_threads,
40914195
max_hosting_storage,
40924196
per_user_secret_quota_bytes,
4197+
per_user_inactive_ttl_secs,
4198+
inactive_user_sweep_interval_secs,
40934199
module_cache_budget_bytes,
40944200
telemetry,
40954201
shutdown_drain_secs,
@@ -4111,6 +4217,14 @@ mod tests {
41114217
per_user_secret_quota_bytes, seed.per_user_secret_quota_bytes,
41124218
"per_user_secret_quota_bytes"
41134219
);
4220+
assert_eq!(
4221+
per_user_inactive_ttl_secs, seed.per_user_inactive_ttl_secs,
4222+
"per_user_inactive_ttl_secs"
4223+
);
4224+
assert_eq!(
4225+
inactive_user_sweep_interval_secs, seed.inactive_user_sweep_interval_secs,
4226+
"inactive_user_sweep_interval_secs"
4227+
);
41144228
assert_eq!(
41154229
module_cache_budget_bytes, seed.module_cache_budget_bytes,
41164230
"module_cache_budget_bytes"
@@ -4216,6 +4330,7 @@ mod tests {
42164330
per_user_op_rate_limit,
42174331
per_user_op_burst,
42184332
per_user_export_min_interval_secs,
4333+
secrets_dir: _, // serde-skip runtime field, repopulated by build()
42194334
} = ws_api;
42204335
assert_eq!(ws_address, seed.ws_api.address, "ws_api.address");
42214336
assert_eq!(ws_port, seed.ws_api.port, "ws_api.port");

0 commit comments

Comments
 (0)