feat: discover subject session history
This commit is contained in:
@@ -1217,6 +1217,26 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
})
|
||||
}
|
||||
|
||||
fn session_public_search(
|
||||
&self,
|
||||
_request: runtime_api::SessionPublicSearchRequest,
|
||||
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
|
||||
Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
|
||||
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
|
||||
message: "Session public search is not supported by this Runtime".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn session_public_read(
|
||||
&self,
|
||||
_request: runtime_api::SessionPublicReadRequest,
|
||||
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
|
||||
Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
|
||||
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
|
||||
message: "Session public read is not supported by this Runtime".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn observation_source(
|
||||
&self,
|
||||
_worker_id: &str,
|
||||
@@ -2073,6 +2093,36 @@ impl RuntimeRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn session_public_search(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
request: runtime_api::SessionPublicSearchRequest,
|
||||
) -> Result<runtime_api::SessionPublicSearchAvailability, RuntimeRegistryError> {
|
||||
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||
self.runtime(runtime_id)?
|
||||
.session_public_search(request)
|
||||
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
code: "session_public_search_failed".to_string(),
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn session_public_read(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
request: runtime_api::SessionPublicReadRequest,
|
||||
) -> Result<runtime_api::SessionPublicReadAvailability, RuntimeRegistryError> {
|
||||
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||
self.runtime(runtime_id)?
|
||||
.session_public_read(request)
|
||||
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
code: "session_public_read_failed".to_string(),
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn observation_source(
|
||||
&self,
|
||||
worker: &RuntimeWorkerRef,
|
||||
@@ -2964,6 +3014,36 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn session_public_search(
|
||||
&self,
|
||||
request: runtime_api::SessionPublicSearchRequest,
|
||||
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
|
||||
if request.workspace_id != self.workspace_id {
|
||||
return Err(
|
||||
"Session public search Workspace does not match embedded Runtime".to_string(),
|
||||
);
|
||||
}
|
||||
let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend");
|
||||
self.runtime
|
||||
.session_public_search_scoped(&scope, &request)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn session_public_read(
|
||||
&self,
|
||||
request: runtime_api::SessionPublicReadRequest,
|
||||
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
|
||||
if request.workspace_id != self.workspace_id {
|
||||
return Err(
|
||||
"Session public read Workspace does not match embedded Runtime".to_string(),
|
||||
);
|
||||
}
|
||||
let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend");
|
||||
self.runtime
|
||||
.session_public_read_scoped(&scope, &request)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn observation_source(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
@@ -3669,6 +3749,9 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
|
||||
if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") {
|
||||
return "workers:delete";
|
||||
}
|
||||
if path.starts_with("/v1/session-public/") && method == "POST" {
|
||||
return "workers:read";
|
||||
}
|
||||
if path.starts_with("/v1/workers/") && method == "GET" {
|
||||
return "workers:read";
|
||||
}
|
||||
@@ -5007,6 +5090,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
.map_err(|diagnostic| diagnostic.message)
|
||||
}
|
||||
|
||||
fn session_public_search(
|
||||
&self,
|
||||
request: runtime_api::SessionPublicSearchRequest,
|
||||
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
|
||||
self.run_runtime_api(
|
||||
self.request_timeout,
|
||||
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||
move |client| async move { client.session_public_search(request).await },
|
||||
)
|
||||
.map_err(|diagnostic| diagnostic.message)
|
||||
}
|
||||
|
||||
fn session_public_read(
|
||||
&self,
|
||||
request: runtime_api::SessionPublicReadRequest,
|
||||
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
|
||||
self.run_runtime_api(
|
||||
self.request_timeout,
|
||||
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||
move |client| async move { client.session_public_read(request).await },
|
||||
)
|
||||
.map_err(|diagnostic| diagnostic.message)
|
||||
}
|
||||
|
||||
fn observation_source(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
|
||||
@@ -957,6 +957,17 @@ CREATE TABLE worker_session_archives (
|
||||
committed_at TEXT NOT NULL, expires_at TEXT,
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
|
||||
CREATE TABLE worker_session_archive_observe_grants (
|
||||
workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL,
|
||||
controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL,
|
||||
subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL,
|
||||
source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE);
|
||||
CREATE INDEX worker_session_archive_observe_grants_controller
|
||||
ON worker_session_archive_observe_grants(
|
||||
workspace_id,controller_runtime_id,controller_worker_id,archive_id);
|
||||
CREATE TABLE worker_tombstones (
|
||||
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
|
||||
|
||||
@@ -132,6 +132,21 @@ pub struct WorkerTombstone {
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerSessionArchiveRecord {
|
||||
pub archive_id: String,
|
||||
pub workspace_id: String,
|
||||
pub worker: RuntimeWorkerRef,
|
||||
pub session_id: String,
|
||||
pub checksum_sha256: String,
|
||||
pub content_bytes: u64,
|
||||
pub policy_id: String,
|
||||
pub policy_revision: u64,
|
||||
pub operation_id: String,
|
||||
pub committed_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerOrphanDiagnostic {
|
||||
pub diagnostic_id: String,
|
||||
@@ -454,6 +469,51 @@ impl SqliteWorkspaceStore {
|
||||
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
|
||||
let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} };
|
||||
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?;
|
||||
let observe_grants = {
|
||||
let mut statement = tx.prepare(
|
||||
"SELECT grant_id, controller_runtime_id, controller_worker_id,
|
||||
permissions_json, created_at
|
||||
FROM worker_control_grants
|
||||
WHERE workspace_id = ?1 AND subject_runtime_id = ?2
|
||||
AND subject_worker_id = ?3 AND revoked_at IS NULL",
|
||||
)?;
|
||||
statement
|
||||
.query_map(
|
||||
params![workspace_id, plan.worker.runtime_id, plan.worker.worker_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
},
|
||||
)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
for (grant_id, controller_runtime_id, controller_worker_id, permissions, created_at) in observe_grants {
|
||||
let permissions: Vec<String> = serde_json::from_str(&permissions)
|
||||
.map_err(|error| StoreError::Store(format!("invalid Worker control grant permissions: {error}")))?;
|
||||
if permissions.iter().any(|permission| permission == "observe") {
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO worker_session_archive_observe_grants
|
||||
(workspace_id,archive_id,controller_runtime_id,controller_worker_id,
|
||||
subject_runtime_id,subject_worker_id,source_grant_id,granted_at)
|
||||
VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
workspace_id,
|
||||
a.archive_id,
|
||||
controller_runtime_id,
|
||||
controller_worker_id,
|
||||
plan.worker.runtime_id,
|
||||
plan.worker.worker_id,
|
||||
grant_id,
|
||||
created_at
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}else if plan.session_disposition==SessionDisposition::Archive{return Err(StoreError::InvalidInput("archive manifest missing".into()));}
|
||||
match plan.diagnostics_disposition {
|
||||
DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())),
|
||||
@@ -592,6 +652,77 @@ impl SqliteWorkspaceStore {
|
||||
) -> crate::Result<Option<WorkerTombstone>> {
|
||||
self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from))
|
||||
}
|
||||
|
||||
pub fn worker_session_archive(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
session_id: &str,
|
||||
) -> crate::Result<Option<WorkerSessionArchiveRecord>> {
|
||||
self.with_conn(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT archive_id, checksum_sha256, content_bytes, policy_id,
|
||||
policy_revision, operation_id, committed_at, expires_at
|
||||
FROM worker_session_archives
|
||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||
AND session_id = ?4",
|
||||
params![
|
||||
workspace_id,
|
||||
worker.runtime_id,
|
||||
worker.worker_id,
|
||||
session_id
|
||||
],
|
||||
|row| {
|
||||
Ok(WorkerSessionArchiveRecord {
|
||||
archive_id: row.get(0)?,
|
||||
workspace_id: workspace_id.to_string(),
|
||||
worker: worker.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
checksum_sha256: row.get(1)?,
|
||||
content_bytes: row.get::<_, i64>(2)? as u64,
|
||||
policy_id: row.get(3)?,
|
||||
policy_revision: row.get::<_, i64>(4)? as u64,
|
||||
operation_id: row.get(5)?,
|
||||
committed_at: row.get(6)?,
|
||||
expires_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(StoreError::from)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn worker_session_archive_observable_by(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
archive_id: &str,
|
||||
controller: &RuntimeWorkerRef,
|
||||
subject: &RuntimeWorkerRef,
|
||||
) -> crate::Result<bool> {
|
||||
self.with_conn(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM worker_session_archive_observe_grants
|
||||
WHERE workspace_id = ?1 AND archive_id = ?2
|
||||
AND controller_runtime_id = ?3 AND controller_worker_id = ?4
|
||||
AND subject_runtime_id = ?5 AND subject_worker_id = ?6
|
||||
)",
|
||||
params![
|
||||
workspace_id,
|
||||
archive_id,
|
||||
controller.runtime_id,
|
||||
controller.worker_id,
|
||||
subject.runtime_id,
|
||||
subject.worker_id
|
||||
],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn orphan_diagnostic(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 75;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 76;
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||
@@ -72,6 +72,8 @@ const ORDERED_WORKER_PROJECTION_MIGRATION_NAME: &str =
|
||||
"ordered Worker projection subscriptions without revision reconciliation";
|
||||
const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str =
|
||||
"Runtime-scoped durable Worker catalog identity";
|
||||
const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str =
|
||||
"preserve explicit observe grants for committed Worker Session archives";
|
||||
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||
@@ -244,6 +246,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME,
|
||||
apply: migrate_runtime_scoped_worker_identity_v74_to_v75,
|
||||
},
|
||||
Migration {
|
||||
version: 76,
|
||||
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME,
|
||||
apply: migrate_archive_observe_grants_v75_to_v76,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -14147,6 +14154,47 @@ fn migrate_runtime_scoped_worker_identity_v74_to_v75(conn: &Connection) -> Resul
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if current != 75 {
|
||||
return Err(Error::Store(format!(
|
||||
"expected schema version 75 before {ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME} migration, found {current}"
|
||||
)));
|
||||
}
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS worker_session_archive_observe_grants (
|
||||
workspace_id TEXT NOT NULL,
|
||||
archive_id TEXT NOT NULL,
|
||||
controller_runtime_id TEXT NOT NULL,
|
||||
controller_worker_id TEXT NOT NULL,
|
||||
subject_runtime_id TEXT NOT NULL,
|
||||
subject_worker_id TEXT NOT NULL,
|
||||
source_grant_id TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (
|
||||
workspace_id, archive_id,
|
||||
controller_runtime_id, controller_worker_id,
|
||||
source_grant_id
|
||||
),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS worker_session_archive_observe_grants_controller
|
||||
ON worker_session_archive_observe_grants(
|
||||
workspace_id, controller_runtime_id, controller_worker_id, archive_id
|
||||
);
|
||||
"#,
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![76_i64, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
||||
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
||||
@@ -16617,6 +16665,10 @@ mod tests {
|
||||
version: 75,
|
||||
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 76,
|
||||
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -16699,6 +16751,7 @@ mod tests {
|
||||
75,
|
||||
RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
(76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -16985,7 +17038,7 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
||||
73, 74, 75
|
||||
73, 74, 75, 76
|
||||
]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
@@ -16994,7 +17047,7 @@ mod tests {
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 26);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 27);
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||
|
||||
@@ -175,6 +175,12 @@ impl SubjectSessionAttribution {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubjectSessionAttributionPage {
|
||||
pub items: Vec<SubjectSessionAttribution>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RevisionProposalIntent {
|
||||
@@ -1208,6 +1214,93 @@ impl SubjektivStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads one bounded immutable attribution page without materializing the
|
||||
/// subject's complete history. `snapshot_at` fixes the initial discovery
|
||||
/// boundary; later Host attributions require a fresh traversal.
|
||||
pub fn subject_session_attribution_page(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
session_id: Option<&str>,
|
||||
snapshot_at: &str,
|
||||
after: Option<(&str, &str)>,
|
||||
limit: usize,
|
||||
) -> Result<SubjectSessionAttributionPage> {
|
||||
validate_label("subject id", subject_id)?;
|
||||
validate_label("attribution snapshot", snapshot_at)?;
|
||||
if let Some(session_id) = session_id {
|
||||
validate_label("session id", session_id)?;
|
||||
}
|
||||
if let Some((attributed_at, session_id)) = after {
|
||||
validate_label("attribution cursor timestamp", attributed_at)?;
|
||||
validate_label("attribution cursor session id", session_id)?;
|
||||
}
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"subject Session attribution page limit must be between 1 and 100".to_string(),
|
||||
));
|
||||
}
|
||||
let query_limit = limit.saturating_add(1);
|
||||
self.database.try_with_connection(|connection| {
|
||||
let raw = match (session_id, after) {
|
||||
(Some(session_id), _) => {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT record_json FROM subject_session_attributions
|
||||
WHERE subject_id = ?1 AND session_id = ?2 AND attributed_at <= ?3
|
||||
ORDER BY attributed_at DESC, session_id ASC
|
||||
LIMIT ?4",
|
||||
)?;
|
||||
let rows = statement.query_map(
|
||||
params![subject_id, session_id, snapshot_at, query_limit as i64],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
}
|
||||
(None, Some((after_at, after_session_id))) => {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT record_json FROM subject_session_attributions
|
||||
WHERE subject_id = ?1 AND attributed_at <= ?2
|
||||
AND (attributed_at < ?3 OR (attributed_at = ?3 AND session_id > ?4))
|
||||
ORDER BY attributed_at DESC, session_id ASC
|
||||
LIMIT ?5",
|
||||
)?;
|
||||
let rows = statement.query_map(
|
||||
params![
|
||||
subject_id,
|
||||
snapshot_at,
|
||||
after_at,
|
||||
after_session_id,
|
||||
query_limit as i64
|
||||
],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
}
|
||||
(None, None) => {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT record_json FROM subject_session_attributions
|
||||
WHERE subject_id = ?1 AND attributed_at <= ?2
|
||||
ORDER BY attributed_at DESC, session_id ASC
|
||||
LIMIT ?3",
|
||||
)?;
|
||||
let rows = statement.query_map(
|
||||
params![subject_id, snapshot_at, query_limit as i64],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
}
|
||||
};
|
||||
let mut items = raw
|
||||
.into_iter()
|
||||
.map(|raw| parse_session_attribution(&raw))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let has_more = items.len() > limit;
|
||||
if has_more {
|
||||
items.truncate(limit);
|
||||
}
|
||||
Ok(SubjectSessionAttributionPage { items, has_more })
|
||||
})
|
||||
}
|
||||
|
||||
pub fn staging_candidate(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
@@ -3678,6 +3771,88 @@ mod tests {
|
||||
assert!(store.session_attribution("session-2").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribution_pages_are_bounded_descending_and_snapshot_fixed() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap();
|
||||
for (session, at) in [
|
||||
("session-1", "2026-09-28T10:00:00.000Z"),
|
||||
("session-2", "2026-09-28T11:00:00.000Z"),
|
||||
("session-3", "2026-09-28T11:00:00.000Z"),
|
||||
] {
|
||||
store
|
||||
.record_session_attribution(attribution(
|
||||
&subject.id,
|
||||
"runtime-1",
|
||||
"worker-1",
|
||||
session,
|
||||
at,
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let first = store
|
||||
.subject_session_attribution_page(
|
||||
&subject.id,
|
||||
None,
|
||||
"2026-09-28T11:30:00.000Z",
|
||||
None,
|
||||
2,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
first
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.session_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["session-2", "session-3"]
|
||||
);
|
||||
assert!(first.has_more);
|
||||
|
||||
store
|
||||
.record_session_attribution(attribution(
|
||||
&subject.id,
|
||||
"runtime-2",
|
||||
"worker-2",
|
||||
"session-later",
|
||||
"2026-09-28T12:00:00.000Z",
|
||||
))
|
||||
.unwrap();
|
||||
let last = first.items.last().unwrap();
|
||||
let second = store
|
||||
.subject_session_attribution_page(
|
||||
&subject.id,
|
||||
None,
|
||||
"2026-09-28T11:30:00.000Z",
|
||||
Some((&last.attributed_at, &last.session_id)),
|
||||
2,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(second.items.len(), 1);
|
||||
assert_eq!(second.items[0].session_id, "session-1");
|
||||
assert!(!second.has_more);
|
||||
assert!(
|
||||
second
|
||||
.items
|
||||
.iter()
|
||||
.all(|item| item.session_id != "session-later")
|
||||
);
|
||||
|
||||
let exact = store
|
||||
.subject_session_attribution_page(
|
||||
&subject.id,
|
||||
Some("session-1"),
|
||||
"2026-09-28T11:30:00.000Z",
|
||||
None,
|
||||
20,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(exact.items.len(), 1);
|
||||
assert!(!exact.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -77,6 +77,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"worker_registry_projection_removals",
|
||||
"worker_removal_operations",
|
||||
"worker_retention_audit_events",
|
||||
"worker_session_archive_observe_grants",
|
||||
"worker_session_archives",
|
||||
"worker_singleton_owners",
|
||||
"worker_tombstones",
|
||||
|
||||
Reference in New Issue
Block a user