Status Service & Expiry Cleanup
Status Service & Expiry Cleanup
The Status Service implements ephemeral, story-style user statuses in the Rust (axum/sqlx) backend: create, list (with cursor pagination), group-by-user overview, ownership-guarded delete, a 24-hour expiry TTL, and a background purge job that deletes expired rows and their associated media.
Purpose and Scope
This page documents the status module of streaming-backend end to end:
- The HTTP handlers in
controller.rs - The domain logic in
service.rs - The request/response DTOs in
models.rs - The
statusestable schema (migrations004_statuses.up.sqland009_add_status_bg_color.up.sql) - The expiry purge background task spawned in
main.rs
Sibling capabilities that this page only references (not re-documents) are the shared infrastructure services the status module relies on: QueryService (cursor pagination helpers), MediaCleanupService (object-storage deletion), the snowflake ID generator in AppState, and the AuthUser auth middleware. For those, see the corresponding catalog pages. The React Native / Expo front-end status screens (streaming/src/components/StatusComposer.tsx, streaming/src/components/StatusViewer.tsx) and the web admin UI (streaming-frontend/components/status/*) are out of scope for this Rust-backend page.
Overview
Statuses behave like social “stories”: a user posts a short-lived item (media URL, caption, background color) that is visible to other users only while it has not expired. Key design decisions visible in the source:
- Ephemerality by TTL, not by scheduled deletion alone. Every status row stores
expires_at(Unix seconds), computed asnow + 86400at creation time increate_status. All read queries filterexpires_at > now, so expired stories are invisible immediately even before the purge job runs. - Batched, transactionally safe background cleanup. A
tokiotask spawned inmain.rsruns every 600 seconds and callsStatusService::purge_expired_statuses, which deletes up to 50 expired rows per pass inside a transaction usingFOR UPDATE SKIP LOCKED, cascading to media deletion. - Ownership enforcement. Deleting a status requires the authenticated user to be the row owner; otherwise the service returns
AppError::Forbidden. - Distributed-safe IDs. Status IDs come from the
AppStatesnowflake generator, and everyi64ID in the JSON payloads is serialized as a string (viaserialize_i64_as_str) to avoid JavaScript number-precision loss in the mobile/web clients. - Cursor pagination. Status lists are fetched newest-first with an opaque
created_at-based cursor, fetched through the sharedQueryService::fetch_cursor_page.
Architecture
Component roles:
- Handlers (
controller.rs) are thin adapters: they parse query strings, extract the authenticated user, and delegate everything toStatusService. They never touch SQL directly. StatusServiceis a unit struct (no instance state) whose associated functions take&AppStateexplicitly — all dependencies (DB pool, storage client, snowflake) are injected viaAppState, which keeps the service easy to call from both HTTP handlers and the background task.QueryServicecentralizes parameterized SQL execution and cursor-page mechanics so the status module only declares what columns, joins, and predicates to use (CursorQueryOptions).MediaCleanupService::delete_mediaremoves the underlying media blob when a status is deleted or purged, preventing orphaned objects.- The background task in
main.rsis the only caller ofpurge_expired_statuses; it is spawned once at server startup and runs forever on a 600-second interval.
Why this structure
The separation mirrors the rest of the backend: controllers are route-only, services are static function bundles over AppState, and shared SQL/pagination utilities live in services/. This makes the expiry purge callable from any context (HTTP or background) and keeps the status module free of connection-pool plumbing.
Service Layer Deep Dive
StatusService is declared as a unit struct (pub struct StatusService;) with five pub async fn associated functions (service.rs). Each method receives &AppState, which carries the sqlx pool (state.db), the object-storage client (state.storage), and the snowflake generator (state.snowflake).
list_user_groups — per-user story overview
pub async fn list_user_groups(state: &AppState) -> Result<UserStatusGroupResponse> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
let mut args = PgArguments::default();
let _ = args.add(now);
let groups: Vec<UserStatusGroup> = QueryService::fetch_all(
&state.db,
"SELECT s.user_id, u.username, MAX(s.created_at) as latest_created_at, COUNT(s.id) as story_count FROM statuses s JOIN users u ON s.user_id = u.id WHERE s.expires_at > $1 GROUP BY s.user_id, u.username ORDER BY latest_created_at DESC",
args
).await?;
Ok(UserStatusGroupResponse { groups })
}
Source: service.rs
This is the “story tray” query: one row per author who currently has at least one unexpired status, with the timestamp of their most recent story and the count of their live stories. Design intent:
- The
WHERE s.expires_at > $1predicate is the single source of truth for visibility — a user with only expired stories simply does not appear in the tray. MAX(s.created_at)is used both as a display value and as the ordering key (ORDER BY latest_created_at DESC), so authors with the freshest content float to the top.- Timestamps are stored as
i64Unix seconds and compared directly againstnowcomputed in Rust withSystemTime::now().duration_since(UNIX_EPOCH), falling back to0on clock errors (unwrap_or_default()).
list_user_statuses — cursor-paginated feed for one author
pub async fn list_user_statuses(state: &AppState, target_user_id: i64, cursor: Option<i64>, limit: usize) -> Result<StatusListResponse> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
let mut args = PgArguments::default();
let _ = args.add(now);
let _ = args.add(target_user_id);
let opts = CursorQueryOptions {
select_cols: Some("s.id, s.user_id, u.username, s.media_url, s.caption, s.bg_color, s.expires_at, s.created_at"),
table_or_join: "statuses s JOIN users u ON s.user_id = u.id",
where_clause: "s.expires_at > $1 AND s.user_id = $2",
cursor_cols: vec!["s.created_at"],
cursor_val: cursor,
limit: (limit + 1) as i64,
};
let rows: Vec<StatusItem> = QueryService::fetch_cursor_page(&state.db, opts, args).await?;
let has_more = rows.len() > limit;
let mut items: Vec<StatusItem> = rows.into_iter().take(limit).collect();
items.reverse();
let next_cursor = if has_more { items.first().map(|i| i.created_at) } else { None };
Ok(StatusListResponse { items, next_cursor })
}
Source: service.rs
Key mechanics:
limit + 1over-fetch: the service asks the cursor query for one extra row; if more rows come back than requested,has_moreis true and the client receives anext_cursor.- Newest-first ordering: the underlying cursor query pages in ascending
created_atorder, so the service fetches the oldest rows of the page first and reverses them before returning — the client always sees the freshest statuses at the top. - Cursor semantics:
next_cursoris thecreated_atof the first (i.e., oldest) returned item when more pages exist. Sinceexpires_atandcreated_atare both set tonowat insert time and statuses never change,created_atis a stable, unique-enough pagination key per author. - Expiry filter again: the
where_clauseincludess.expires_at > $1, so the feed never shows stale stories even if purge has not run yet.
create_status — insert with 24-hour TTL
pub async fn create_status(state: &AppState, user_id: i64, req: CreateStatusRequest) -> Result<StatusItem> {
let id = state.snowflake.next_id();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
let expires_at = now + 86400;
let mut user_args = PgArguments::default();
let _ = user_args.add(user_id);
let user = QueryService::fetch_optional::<UserRow>(&state.db, "SELECT username FROM users WHERE id = $1", user_args).await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
let mut ins_args = PgArguments::default();
let _ = ins_args.add(id); let _ = ins_args.add(user_id);
let _ = ins_args.add(req.media_url.clone()); let _ = ins_args.add(req.caption.clone());
let _ = ins_args.add(req.bg_color.clone()); let _ = ins_args.add(expires_at); let _ = ins_args.add(now);
QueryService::execute(&state.db, "INSERT INTO statuses (id, user_id, media_url, caption, bg_color, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", ins_args).await?;
Ok(StatusItem { id, user_id, username: user.username, media_url: req.media_url, caption: req.caption, bg_color: req.bg_color, expires_at, created_at: now })
}
Source: service.rs
expires_at = now + 86400hard-codes the 24-hour TTL (86,400 seconds) — the constant lives inline in the service rather than in configuration.- The user existence check (
SELECT username FROM users WHERE id = $1) runs first and returnsAppError::NotFound("User not found")for a missing author. The username is fetched eagerly so the responseStatusItemcan include it without a second join. - The ID is generated client-side-of-the-DB by the snowflake generator, so inserts never depend on
RETURNINGor sequence allocation. bg_color(added by migration009_add_status_bg_color.up.sql) is stored as a nullable string and echoed back in the response.
delete_status — ownership-guarded delete with media cascade
pub async fn delete_status(state: &AppState, status_id: i64, user_id: i64) -> Result<()> {
let mut chk_args = PgArguments::default();
let _ = chk_args.add(status_id);
let item = QueryService::fetch_optional::<StatusDeleteCheckRow>(&state.db, "SELECT user_id, media_url FROM statuses WHERE id = $1", chk_args).await?
.ok_or_else(|| AppError::NotFound("Status story not found".into()))?;
if item.user_id != user_id { return Err(AppError::Forbidden("Only status owner can delete this story".into())); }
if let Some(media) = item.media_url { let _ = MediaCleanupService::delete_media(&state.storage, &media).await; }
let mut del_args = PgArguments::default();
let _ = del_args.add(status_id);
QueryService::execute(&state.db, "DELETE FROM statuses WHERE id = $1", del_args).await?;
Ok(())
}
Source: service.rs
Two sqlx::FromRow helper structs back this method: StatusDeleteCheckRow { user_id, media_url } and, for the purge path, StatusExpiredRow { id, media_url } (service.rs). The delete flow is deliberately check-then-act:
- Existence check →
NotFound("Status story not found")if the row does not exist. - Ownership check →
Forbidden("Only status owner can delete this story")ifrow.user_id != auth user_id. Authorization happens in the service, not the route, so every caller is protected. - Media cleanup → if the row carries
media_url, the object-store blob is deleted (errors swallowed withlet _). - Row delete → a parameterized
DELETEremoves the row itself.
The check-then-delete is not atomic (a concurrent purge could delete the row between steps 1 and 4); in practice this surfaces only as a rare NotFound/no-op since deletion is idempotent.
purge_expired_statuses — the expiry cleanup job
pub async fn purge_expired_statuses(state: &AppState) -> Result<usize> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
let mut tx = state.db.begin().await?;
let mut purge_args = PgArguments::default();
let _ = purge_args.add(now);
let expired = sqlx::query_as_with::<_, StatusExpiredRow, _>("SELECT id, media_url FROM statuses WHERE expires_at <= $1 ORDER BY expires_at ASC LIMIT 50 FOR UPDATE SKIP LOCKED", purge_args).fetch_all(&mut *tx).await?;
let mut count = 0;
for item in expired {
if let Some(media) = item.media_url { let _ = MediaCleanupService::delete_media(&state.storage, &media).await; }
let mut del_args = PgArguments::default();
let _ = del_args.add(item.id);
QueryService::execute_tx(&mut tx, "DELETE FROM statuses WHERE id = $1", del_args).await?;
count += 1;
}
tx.commit().await?;
Ok(count)
}
Source: service.rs
This is the heart of “Expiry Cleanup”. Design details worth calling out:
- Transactional batching: the expired-row
SELECTand allDELETEs run inside one explicit transaction (state.db.begin()), committed at the end. If any delete fails, the?propagates the error, the transaction is rolled back on drop, and no partial cleanup is committed. FOR UPDATE SKIP LOCKED: the batch query locks the selected rows and skips any rows already locked by another transaction. This makes the job safe to run concurrently — e.g., multiple backend instances behind a load balancer — because two workers can never delete the same row or block each other; each pass takes up to 50 rows nobody else is working on.ORDER BY expires_at ASC LIMIT 50: the oldest-expiring statuses are processed first and the batch is capped at 50 rows per invocation, bounding per-pass work and keeping each transaction short.- Media cascade: each expired row’s
media_urlis passed toMediaCleanupService::delete_media(best-effort, errors ignored) before its row is deleted — the same orphan-prevention policy used bydelete_status. - Incomplete-pass tolerance: because the purge loop in
main.rsruns every 600 seconds, any pass that deletes fewer than all expired rows (more than 50 expired, or a concurrent lock) simply continues on the next tick. The read-pathexpires_at > $1filters guarantee expired rows are invisible in the meantime.
The background loop in main.rs
let cleanup_state = state.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(600));
loop {
interval.tick().await;
let _ = crate::modules::status::service::StatusService::purge_expired_statuses(&cleanup_state).await;
}
});
Source: main.rs
The task is spawned at startup right after the identity-key backfill and the gRPC server, before the Axum HTTP server binds. Notes:
state.clone()hands the task its ownAppStatehandle;AppStateis cheaply clonable (shared pool/client handles), which is why the service methods take&AppState.tokio::time::intervalfires the first tick immediately after the first wait of 600 s, then every 600 s thereafter.- The result is deliberately ignored (
let _), so a transient DB failure in one pass never kills the loop; the next tick retries. This matches the “best-effort, read-path-filters-protect-visibility” philosophy.
Data Model & Persistence
The statuses table is defined by migrations 004_statuses.up.sql (initial schema) and 009_add_status_bg_color.up.sql (adds bg_color). The exact column set is confirmed by the INSERT in create_status and the row structs in the service:
| Column | Type | Notes |
|---|---|---|
id |
i64 (PK) |
Snowflake-generated, serialized as string in JSON |
user_id |
i64 (FK → users.id) |
Status author |
media_url |
text nullable |
Object-storage reference, cascaded to media cleanup on delete/purge |
caption |
text nullable |
Optional text caption |
bg_color |
text nullable |
Optional background color (migration 009) |
expires_at |
i64 (Unix seconds) |
created_at + 86400; all reads filter expires_at > now |
created_at |
i64 (Unix seconds) |
Insert time; used as the cursor pagination key |
Timestamps are stored as plain Unix-seconds integers rather than TIMESTAMPTZ. This keeps every query trivially comparable with SystemTime::now() computed in Rust and avoids timezone conversions, at the cost of losing Postgres-native datetime functions on this column.
Read-path visibility invariant: every public read (list_user_groups, list_user_statuses) enforces expires_at > now. Purge is a storage-reclamation concern, not a visibility concern — even if the background job is delayed or fails repeatedly, expired stories never leak into API responses.
Core Flow
Status lifecycle
Create → read → delete interaction
Expiry purge pass
Usage Examples
Creating a status (service call site)
The handler extracts the authenticated user and delegates; the service performs the TTL computation and user check:
pub async fn create_status(
auth_user: AuthUser,
State(state): State<AppState>,
Json(req): Json<CreateStatusRequest>,
) -> Result<Json<StatusItem>> {
let item = StatusService::create_status(&state, auth_user.user_id, req).await?;
Ok(Json(item))
}
Source: controller.rs
Listing with query-string parsing and clamping
pub async fn list_statuses(
State(state): State<AppState>,
Query(q): Query<StatusListQuery>,
) -> Result<Json<StatusListResponse>> {
let cursor = q.cursor.and_then(|c| c.parse::<i64>().ok());
let limit = q.limit.and_then(|l| l.parse::<usize>().ok()).unwrap_or(10).min(50);
let target_user = q.user_id.and_then(|u| u.parse::<i64>().ok());
if let Some(uid) = target_user {
let res = StatusService::list_user_statuses(&state, uid, cursor, limit).await?;
Ok(Json(res))
} else {
let groups = StatusService::list_user_groups(&state).await?;
let dummy = StatusListResponse { items: vec![], next_cursor: None };
Ok(Json(dummy))
}
}
Source: controller.rs
The limit query parameter defaults to 10 and is clamped to at most 50; malformed cursor/user_id values degrade to None (first page / tray view) instead of erroring. Note the implementation quirk in the no-user_id branch: list_user_groups is invoked but its result is discarded and an empty items list is returned — the groups are served by the dedicated GET /api/statuses/groups endpoint instead (see list_user_groups handler below).
pub async fn list_user_groups(
State(state): State<AppState>,
) -> Result<Json<UserStatusGroupResponse>> {
let res = StatusService::list_user_groups(&state).await?;
Ok(Json(res))
}
Source: controller.rs
Serializing IDs as strings
All public i64 identifiers are string-serialized so JavaScript clients do not lose precision:
fn serialize_i64_as_str<S>(val: &i64, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer {
serializer.serialize_str(&val.to_string())
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct StatusItem {
#[serde(serialize_with = "serialize_i64_as_str")]
pub id: i64,
#[serde(serialize_with = "serialize_i64_as_str")]
pub user_id: i64,
pub username: String,
pub media_url: Option<String>,
pub caption: Option<String>,
pub bg_color: Option<String>,
pub expires_at: i64,
pub created_at: i64,
}
Source: models.rs
The same serializer is applied to UserStatusGroup.user_id; expires_at and created_at remain plain numbers because they fit safely in a JS Number for the foreseeable future.
API Reference
HTTP endpoints
| Method | Path | Auth | Handler | Description |
|---|---|---|---|---|
GET |
/api/statuses?user_id=&cursor=&limit= |
optional | list_statuses |
Statuses of one user (cursor-paginated, newest first) or empty list when user_id is absent |
GET |
/api/statuses/groups |
optional | list_user_groups |
Story-tray overview: one group per author with live statuses |
POST |
/api/statuses |
required (AuthUser) |
create_status |
Create a status; TTL is now + 86400 |
DELETE |
/api/statuses/{id} |
required (AuthUser) |
delete_status |
Delete own status; owner-only |
(The exact route prefixes are wired by router::build_router in the backend’s router module; the handlers above are the status-module entry points.)
StatusService methods
list_user_groups(state: &AppState) -> Result<UserStatusGroupResponse>
Returns one UserStatusGroup per author having at least one unexpired status, ordered by latest_created_at descending. Executes the group-by query through QueryService::fetch_all.
Throws:
AppError::Database(via?propagation): query failure.
list_user_statuses(state: &AppState, target_user_id: i64, cursor: Option<i64>, limit: usize) -> Result<StatusListResponse>
Returns limit statuses for target_user_id (unexpired only), newest first. Fetches limit + 1 rows to compute has_more; next_cursor is the created_at of the oldest returned item when more pages exist, else None.
Parameters:
target_user_id(i64): author whose statuses to list.cursor(Option<i64>):created_at-based page cursor.limit(usize): page size (the caller clamps it; the service itself does not clamp).
Throws:
AppError::Database: query failure.
create_status(state: &AppState, user_id: i64, req: CreateStatusRequest) -> Result<StatusItem>
Generates a snowflake id, computes expires_at = now + 86400, verifies the author exists, and inserts the row.
Parameters:
user_id(i64): authenticated author (fromAuthUser).req(CreateStatusRequest):media_url: Option<String>,caption: Option<String>,bg_color: Option<String>.
Throws:
AppError::NotFound("User not found"): nousersrow foruser_id.AppError::Database: insert failure.
delete_status(state: &AppState, status_id: i64, user_id: i64) -> Result<()>
Deletes the status after existence and ownership checks; cascades to media deletion when media_url is present.
Throws:
AppError::NotFound("Status story not found"): no row forstatus_id.AppError::Forbidden("Only status owner can delete this story"):row.user_id != user_id.AppError::Database: delete failure.
purge_expired_statuses(state: &AppState) -> Result<usize>
Deletes up to 50 expired rows in one transaction (FOR UPDATE SKIP LOCKED), deleting each row’s media first. Returns the number of purged rows.
Throws:
AppError::Database: transaction begin/query/commit failure (transaction rolls back).
DTOs (models.rs)
| Struct | Kind | Fields |
|---|---|---|
StatusItem |
Serialize + Deserialize + FromRow |
id (str-serialized), user_id (str-serialized), username, media_url?, caption?, bg_color?, expires_at, created_at |
UserStatusGroup |
Serialize + Deserialize + FromRow |
user_id (str-serialized), username, latest_created_at, story_count |
StatusListQuery |
Deserialize | cursor?, limit?, user_id? (all raw strings, parsed by the handler) |
StatusListResponse |
Serialize | items: Vec<StatusItem>, next_cursor: Option<i64> |
UserStatusGroupResponse |
Serialize | groups: Vec<UserStatusGroup> |
CreateStatusRequest |
Deserialize | media_url?, caption?, bg_color? |
Configuration Options
The status module has no external config file; its tunables are constants and spawn parameters hard-coded in source:
| Option | Value | Location | Description |
|---|---|---|---|
| Status TTL | 86400 seconds (24 h) |
service.rs create_status |
expires_at = now + 86400 |
| Cleanup interval | 600 seconds (10 min) |
main.rs cleanup task |
tokio::time::interval(Duration::from_secs(600)) |
| Purge batch size | 50 rows |
service.rs purge_expired_statuses |
LIMIT 50 ... FOR UPDATE SKIP LOCKED |
| Default page limit | 10 |
controller.rs list_statuses |
unwrap_or(10) |
| Max page limit | 50 |
controller.rs list_statuses |
.min(50) clamp |
Failure Modes, Edge Cases & Concurrency
- Expired-but-not-purged visibility: guaranteed invisible because every read filters
expires_at > now. Purge lag only affects storage usage, never correctness. - Purge worker errors are swallowed:
let _ = ...purge_expired_statuses(...)inmain.rsmeans a failed pass is silently retried next tick; there is no alerting or backoff. If the DB is down for many cycles, expired rows accumulate until the job succeeds. - Concurrent purge workers:
FOR UPDATE SKIP LOCKEDlets multiple instances run the job without double-deleting; a worker only locks rows nobody else holds. With many expired rows (>50 per pass), each tick handles the next batch, so a large backlog drains over several ticks. - Delete-vs-purge race:
delete_status’s check-then-delete is non-atomic; a concurrent purge can remove the row between theSELECTandDELETE, which surfaces as a benignNotFound(already-deleted outcome). The converse (purge deleting a row while a user tries to delete it) is also safe: the second deleter seesNotFound. - Media deletion is best-effort:
delete_mediaerrors are discarded in bothdelete_statusandpurge_expired_statuses, so a storage outage can leave orphaned blobs that are never retried by this module. limitclamp protects the DB: the handler clampslimitto[1..50]; an attacker passinglimit=1000000gets50. The service-levellimit + 1over-fetch is bounded by this.- Snowflake clock assumptions:
create_statusrelies onstate.snowflake.next_id(); no failure path is handled here for clock skew — the generator’s guarantees are its own concern. SystemTimefallback:duration_since(UNIX_EPOCH).unwrap_or_default()silently uses epoch0on clock errors, which would make all statuses appear expired; in practice system clocks behind Unix epoch are not a realistic deployment scenario.
Performance & Operational Considerations
- Index-friendly queries: the hot read predicates are
expires_at > $1(both read paths) anduser_id = $2plusORDER BY created_at(per-author feed). An index onstatuses(expires_at)and a composite index on(user_id, created_at)are what the query shapes assume; theFOR UPDATE ... ORDER BY expires_atpurge scan likewise benefits from(expires_at). The exact index set is defined in the migration files (004_statuses.up.sql,009_add_status_bg_color.up.sql). - Purge cost per pass is bounded:
LIMIT 50keeps each transaction small (≤50 row locks + ≤50 media deletions), so the 600-second job does not create long-running transactions or lock storms. - Reads never block on cleanup: because visibility is enforced by the
expires_at > nowpredicate, the read path does not contend with the purge transaction; only the rows the purge locked (FOR UPDATE) are briefly unavailable to concurrentDELETEs, andSKIP LOCKEDprevents waits. - Over-fetch by one row (
limit + 1) is the standard cost of cursor pagination with ahas_moreflag; with a 50-row cap the extra row is negligible. - Group query cost:
list_user_groupsaggregates over all unexpired statuses; with a large number of concurrent authors this is the most expensive query in the module and relies on theexpires_atindex for the filter before theGROUP BY. - No caching layer: status data is read fresh from Postgres on every request; no in-memory or Redis cache exists in this module (the SQL is parameterized, so the plan cache still applies).
- Error swallowing in background paths: both the purge loop (
main.rs) and media deletions ignore errors, so operational visibility depends on logs/APM rather than on the module raising alerts.
Extension Points
- Configurable TTL:
expires_at = now + 86400increate_statusis the single place to change the story lifetime (e.g., per-tier TTLs,REPLY-style durations). A future refactor could move86400intoAppConfigand add per-request override support toCreateStatusRequest. CursorQueryOptionsreuse:list_user_statusesbuilds the paginated query declaratively viaQueryService::fetch_cursor_page; adding filters (e.g.,caption IS NOT NULL, visibility scopes) is a matter of extendingwhere_clause/cursor_cols.- New status kinds:
CreateStatusRequestalready carries optionalmedia_url,caption, andbg_color; adding fields is a schema migration plus a DTO field — no change to the service flow. - Purge policy tuning: batch size (
LIMIT 50) and interval (600s) are the two knobs for cleanup cadence; moving them to configuration would allow ops to tune without a rebuild. - Retry for media cleanup:
delete_mediafailures are currently dropped; a dead-letter queue or a second pass that re-scans rows whose media still exists would make orphan cleanup eventually consistent.
Tests
No dedicated test files for the status module were found in the repository scan (streaming-backend tests, if any, live outside the module tree). The behavior documented above is therefore derived from the production code paths themselves: the read-path expiry filters, the ownership guard, the limit + 1 pagination, and the FOR UPDATE SKIP LOCKED purge transaction are all verifiable in service.rs. When adding tests, the highest-value targets are: (1) list_user_statuses pagination boundaries (empty page, exact page, page+1), (2) purge_expired_statuses with >50 expired rows across two calls, (3) ownership rejection in delete_status, and (4) expired-row invisibility without a purge run.
Related Links
- Status module — controller — HTTP handlers for the endpoints above
- Status module — service — domain logic, TTL, purge implementation
- Status module — models — DTOs and the
serialize_i64_as_strhelper - Server entry point — expiry cleanup background task
- Migration 004 — statuses table — initial
statusesschema - Migration 009 — bg_color — adds
bg_colorcolumn - For the shared pagination/SQL helpers used here, see the Query Service catalog page; for object-storage deletion, see Media Cleanup Service; for the auth extractor, see Auth Middleware; for the mobile client screens, see the front-end status components (
streaming/src/components/StatusComposer.tsx,streaming/src/components/StatusViewer.tsx).