use std::path::Path;
use std::sync::{Arc, LazyLock};
use crate::journal::{JournalError, PartitionJournal};
use arc_swap::ArcSwapOption;
use arrow::array::Array as _;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use datafusion::execution::memory_pool::FairSpillPool;
use datafusion::execution::runtime_env::RuntimeEnvBuilder;
use datafusion::execution::{SessionState, SessionStateBuilder};
use polyc_crypto::session;
#[cfg(any(test, feature = "test-util"))]
use polyc_crypto::session::RevokedTokens;
#[cfg(any(test, feature = "test-util"))]
use polyc_crypto::signing_role::SessionRole;
use polyc_crypto::signing_role::{
HandoffRole, RoleTrustSet, SigningRole as _, TurnReadRole, TurnReadSigner,
};
use polyc_persona::{PersonaHost, ScopeResolution};
use serde::{Deserialize, Serialize};
use crate::cache::{CacheConfig, DecodeCache, Lookup};
use crate::dashboard::DashboardCell;
use crate::engine::{
PartitionEvents, PartitionTables, QueryEngine, QueryLimits, ReferenceData,
decode_partition_tables,
};
use crate::output::{self, QueryResultJson};
use crate::routine_catalog::RoutineCatalog;
use crate::session::QueryScope;
use crate::statement_gate;
const ROUTINE_SCHEDULER_PARTITION: &str = "routine-scheduler";
const SEARCH_SCOPE_CAP: usize = 5_000;
const SEARCH_SCOPE_HASH_DOMAIN: &[u8] = b"polychrome.search.scope.v1";
fn is_admitted_partition(partition: &str) -> bool {
partition.starts_with("conv-") || partition == ROUTINE_SCHEDULER_PARTITION
}
pub type PersonaCell = Arc<ArcSwapOption<PersonaHost>>;
#[async_trait::async_trait]
pub trait PersonaSource: Send + Sync {
async fn active_persona(
&self,
persona_id: String,
) -> Result<Option<polyc_persona::ActivePersona>, polyc_persona::PersonaError>;
async fn participations(
&self,
persona_id: String,
) -> Result<
Vec<polyc_proto::proto::polychrome::persona::v1::Participation>,
polyc_persona::PersonaError,
>;
async fn participation_scope(
&self,
persona_id: String,
cap: usize,
) -> Result<ScopeResolution, polyc_persona::PersonaError>;
async fn usage_rollup_index(&self) -> Result<Vec<String>, polyc_persona::PersonaError>;
async fn reference_snapshot(
&self,
persona_id: String,
) -> Result<Option<polyc_persona::PersonaReferenceSnapshot>, polyc_persona::PersonaError>;
}
#[async_trait::async_trait]
impl PersonaSource for PersonaHost {
async fn active_persona(
&self,
persona_id: String,
) -> Result<Option<polyc_persona::ActivePersona>, polyc_persona::PersonaError> {
Self::active_persona(self, persona_id).await
}
async fn participations(
&self,
persona_id: String,
) -> Result<
Vec<polyc_proto::proto::polychrome::persona::v1::Participation>,
polyc_persona::PersonaError,
> {
Self::participations(self, persona_id).await
}
async fn participation_scope(
&self,
persona_id: String,
cap: usize,
) -> Result<ScopeResolution, polyc_persona::PersonaError> {
Self::participation_scope(self, persona_id, cap).await
}
async fn usage_rollup_index(&self) -> Result<Vec<String>, polyc_persona::PersonaError> {
Self::usage_rollup_index(self).await
}
async fn reference_snapshot(
&self,
persona_id: String,
) -> Result<Option<polyc_persona::PersonaReferenceSnapshot>, polyc_persona::PersonaError> {
Self::reference_snapshot(self, persona_id).await
}
}
#[derive(Clone)]
enum PersonaAccess {
#[allow(
dead_code,
reason = "available only to the explicit test-support constructor"
)]
Legacy(PersonaCell),
Current(Arc<dyn PersonaSource>),
}
impl PersonaAccess {
fn load_full(&self) -> Option<Arc<dyn PersonaSource>> {
match self {
Self::Legacy(cell) => cell
.load_full()
.map(|host| -> Arc<dyn PersonaSource> { host }),
Self::Current(source) => Some(Arc::clone(source)),
}
}
}
#[derive(Debug, Clone)]
pub struct AdminPrincipal {
persona_id: String,
}
impl AdminPrincipal {
#[must_use]
pub fn persona_id(&self) -> &str {
&self.persona_id
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
pub enum GrantSubject {
Turn(String),
WebSession(String),
}
impl GrantSubject {
#[allow(
clippy::missing_const_for_fn,
reason = "String's Deref to str isn't const (E0015)"
)]
#[must_use]
pub fn turn_id(&self) -> Option<&str> {
match self {
Self::Turn(id) => Some(id),
Self::WebSession(_) => None,
}
}
#[allow(clippy::missing_const_for_fn, reason = "see turn_id's own doc above")]
#[must_use]
pub fn web_session_id(&self) -> Option<&str> {
match self {
Self::WebSession(id) => Some(id),
Self::Turn(_) => None,
}
}
}
#[derive(Debug, Clone)]
pub struct ConversationGrantPrincipal {
conversation_id: String,
subject: GrantSubject,
}
impl ConversationGrantPrincipal {
#[must_use]
pub fn conversation_id(&self) -> &str {
&self.conversation_id
}
#[must_use]
pub const fn subject(&self) -> &GrantSubject {
&self.subject
}
#[must_use]
#[allow(
clippy::missing_const_for_fn,
reason = "GrantSubject::turn_id isn't const either — see its own doc"
)]
pub fn turn_id(&self) -> Option<&str> {
self.subject.turn_id()
}
}
#[derive(Debug, Clone)]
pub struct PersonaPrincipal {
persona_id: String,
}
impl PersonaPrincipal {
#[must_use]
pub fn persona_id(&self) -> &str {
&self.persona_id
}
}
#[derive(Debug, Clone)]
pub enum Principal {
Admin(AdminPrincipal),
ConversationGrant(ConversationGrantPrincipal),
Persona(PersonaPrincipal),
}
#[derive(Debug, thiserror::Error)]
pub enum PrincipalError {
#[error("no valid admin session")]
InvalidSession,
#[error("persona store unavailable")]
StoreUnavailable,
#[error("this account is not authorized for fleet-wide queries")]
NotAuthorizedForFleet,
#[error("conversation grant token invalid")]
InvalidGrant,
#[error("conversation grant token expired")]
GrantExpired,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchScope {
conversation_ids: Vec<String>,
hash: String,
}
impl SearchScope {
#[must_use]
pub fn conversation_ids(&self) -> &[String] {
&self.conversation_ids
}
#[must_use]
pub const fn count(&self) -> usize {
self.conversation_ids.len()
}
#[must_use]
pub fn hash(&self) -> &str {
&self.hash
}
}
#[derive(Debug, thiserror::Error)]
pub enum SearchScopeError {
#[error("persona is not active")]
PersonaNotActive,
#[error("persona store unavailable")]
StoreUnavailable,
#[error("participation scope of {count} conversations exceeds the search cap")]
OverCap {
count: usize,
},
}
fn canonical_search_scope(conversation_ids: Vec<String>) -> (Vec<String>, String) {
let mut encoded: Vec<(Vec<u8>, String)> = conversation_ids
.into_iter()
.map(|id| {
let bytes = id.as_bytes();
let len = u32::try_from(bytes.len())
.expect("conversation id byte length exceeds u32 — not an id this system mints");
let mut record = Vec::with_capacity(4 + bytes.len());
record.extend_from_slice(&len.to_be_bytes());
record.extend_from_slice(bytes);
(record, id)
})
.collect();
encoded.sort_by(|(a, _), (b, _)| a.cmp(b));
encoded.dedup_by(|(a, _), (b, _)| a == b);
let total_len = SEARCH_SCOPE_HASH_DOMAIN.len()
+ 1
+ encoded
.iter()
.map(|(record, _)| record.len())
.sum::<usize>();
let mut buf = Vec::with_capacity(total_len);
buf.extend_from_slice(SEARCH_SCOPE_HASH_DOMAIN);
buf.push(0);
for (record, _) in &encoded {
buf.extend_from_slice(record);
}
let hash = blake3::hash(&buf).to_hex().to_string();
let canonical_ids = encoded.into_iter().map(|(_, id)| id).collect();
(canonical_ids, hash)
}
const GRANT_KIND: &str = "query_conversation_grant.v2";
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct GrantClaims {
kind: String,
issuer: String,
key_id: String,
conversation_id: String,
subject: GrantSubject,
expires_at_ms: u64,
}
#[must_use]
pub fn mint_conversation_grant(
signer: &TurnReadSigner,
conversation_id: &str,
subject: GrantSubject,
expires_at_ms: u64,
) -> String {
let claims = GrantClaims {
kind: GRANT_KIND.to_owned(),
issuer: TurnReadRole::ISSUER.to_owned(),
key_id: signer.identity().key_id().to_owned(),
conversation_id: conversation_id.to_owned(),
subject,
expires_at_ms,
};
let canonical = serde_json::to_vec(&claims).expect("GrantClaims always serializes");
let signature = signer.sign_turn_read_capability(&canonical);
format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(canonical),
URL_SAFE_NO_PAD.encode(signature)
)
}
fn build_base_session_state(
memory_bytes: usize,
spill_dir: &Path,
spill_quota_bytes: u64,
) -> SessionState {
let runtime = RuntimeEnvBuilder::new()
.with_memory_pool(Arc::new(FairSpillPool::new(memory_bytes)))
.with_temp_file_path(spill_dir)
.with_max_temp_directory_size(spill_quota_bytes)
.build_arc()
.unwrap_or_else(|err| {
panic!(
"building the process-wide DataFusion runtime failed — most likely `spill_dir` \
({}) does not exist and could not be created; see \
`build_base_session_state`'s own doc for the ordering dependency on a paired \
manifest PR this may indicate was skipped: {err}",
spill_dir.display()
)
});
SessionStateBuilder::new()
.with_runtime_env(runtime)
.with_default_features()
.build()
}
pub struct QueryAuthority {
base_state: SessionState,
journal: Arc<dyn PartitionJournal>,
persona: PersonaAccess,
dashboard: DashboardCell,
#[cfg(any(test, feature = "test-util"))]
legacy_revoked: Option<Arc<RevokedTokens>>,
bearer_authority: Option<Arc<dyn polyc_session_family::authority::BrowserSessionAuthority>>,
turn_read_trust: RoleTrustSet<TurnReadRole>,
#[cfg(any(test, feature = "test-util"))]
legacy_session_trust: Option<RoleTrustSet<SessionRole>>,
trusted_signers: Vec<Vec<u8>>,
handoff_trust: RoleTrustSet<HandoffRole>,
limits: QueryLimits,
routine_catalog: Option<Arc<dyn RoutineCatalog>>,
cache: Arc<DecodeCache>,
#[cfg(test)]
test_search_scope_cap: std::sync::Mutex<Option<usize>>,
}
impl crate::feed::PartitionInvalidation for QueryAuthority {
fn invalidate_partition(&self, partition: &str) {
Self::invalidate_partition(self, partition);
}
}
impl QueryAuthority {
#[must_use]
#[allow(clippy::too_many_arguments)]
#[cfg(any(test, feature = "test-util"))]
pub fn new(
journal: Arc<dyn PartitionJournal>,
persona: PersonaCell,
dashboard: DashboardCell,
revoked: Arc<RevokedTokens>,
test_signer_public_key: Vec<u8>,
trusted_signers: Vec<Vec<u8>>,
limits: QueryLimits,
routine_catalog: Option<Arc<dyn RoutineCatalog>>,
cache_config: CacheConfig,
handoff_trust: RoleTrustSet<HandoffRole>,
) -> Self {
let cache = Arc::new(DecodeCache::new(cache_config));
Self {
base_state: build_base_session_state(
limits.memory_bytes,
&limits.spill_dir,
limits.spill_quota_bytes,
),
journal,
persona: PersonaAccess::Legacy(persona),
dashboard,
legacy_revoked: Some(revoked),
bearer_authority: None,
turn_read_trust: RoleTrustSet::from_public_keys(vec![test_signer_public_key.clone()])
.expect("test signer public key is encoded ed25519"),
legacy_session_trust: Some(
RoleTrustSet::from_public_keys(vec![test_signer_public_key])
.expect("test signer public key is encoded ed25519"),
),
trusted_signers,
handoff_trust,
limits,
routine_catalog,
cache,
#[cfg(test)]
test_search_scope_cap: std::sync::Mutex::new(None),
}
}
#[cfg(test)]
fn with_turn_read_trust_for_test(mut self, trust: RoleTrustSet<TurnReadRole>) -> Self {
self.turn_read_trust = trust;
self
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new_state_backed(
journal: Arc<dyn PartitionJournal>,
persona: Arc<dyn PersonaSource>,
dashboard: DashboardCell,
turn_read_trust: RoleTrustSet<TurnReadRole>,
trusted_signers: Vec<Vec<u8>>,
limits: QueryLimits,
routine_catalog: Option<Arc<dyn RoutineCatalog>>,
cache_config: CacheConfig,
authority: Arc<dyn polyc_session_family::authority::BrowserSessionAuthority>,
handoff_trust: RoleTrustSet<HandoffRole>,
) -> Self {
let cache = Arc::new(DecodeCache::new(cache_config));
Self {
base_state: build_base_session_state(
limits.memory_bytes,
&limits.spill_dir,
limits.spill_quota_bytes,
),
journal,
persona: PersonaAccess::Current(persona),
dashboard,
#[cfg(any(test, feature = "test-util"))]
legacy_revoked: None,
bearer_authority: Some(authority),
turn_read_trust,
#[cfg(any(test, feature = "test-util"))]
legacy_session_trust: None,
trusted_signers,
handoff_trust,
limits,
routine_catalog,
cache,
#[cfg(test)]
test_search_scope_cap: std::sync::Mutex::new(None),
}
}
#[cfg(test)]
pub(crate) fn set_test_search_scope_cap(&self, cap: usize) {
*self.test_search_scope_cap.lock().expect("poison") = Some(cap);
}
pub fn invalidate_partition(&self, partition: &str) {
self.cache.evict(partition);
}
pub async fn verify_admin_session(
&self,
token: &str,
now_ms: u64,
) -> Result<Principal, PrincipalError> {
let claims = if let Some(authority) = &self.bearer_authority {
authority
.verify_bearer(token, now_ms)
.await
.map_err(|error| match error {
polyc_session_family::authority::SessionAuthorityError::Invalid => {
PrincipalError::InvalidSession
}
_ => PrincipalError::StoreUnavailable,
})?
} else {
#[cfg(not(any(test, feature = "test-util")))]
return Err(PrincipalError::StoreUnavailable);
#[cfg(any(test, feature = "test-util"))]
{
let legacy = session::verify_session_with_trust(
self.legacy_session_trust
.as_ref()
.ok_or(PrincipalError::StoreUnavailable)?,
token,
now_ms,
self.legacy_revoked
.as_ref()
.ok_or(PrincipalError::StoreUnavailable)?,
)
.ok_or(PrincipalError::InvalidSession)?;
polyc_crypto::session::AuthorizedSessionClaims {
issuer: legacy.issuer,
key_id: legacy.key_id,
session_id: "legacy-test-session".to_owned(),
authorization_epoch: 0,
subject: legacy.subject,
scopes: legacy.scopes,
issued_ms: legacy.issued_ms,
expires_ms: legacy.expires_ms,
}
}
};
if !claims.has_scope(session::SessionScope::ExplorerRead) {
return Err(PrincipalError::InvalidSession);
}
let persona_id = claims
.subject
.persona_id()
.ok_or(PrincipalError::InvalidSession)?
.to_owned();
let Some(persona) = self.persona.load_full() else {
return Err(PrincipalError::StoreUnavailable);
};
match persona.active_persona(persona_id).await {
Ok(Some(active)) => {
let persona_id = active.persona_id;
if active.admin {
Ok(Principal::Admin(AdminPrincipal { persona_id }))
} else {
Ok(Principal::Persona(PersonaPrincipal { persona_id }))
}
}
Ok(None) => Err(PrincipalError::NotAuthorizedForFleet),
Err(_store_error) => Err(PrincipalError::StoreUnavailable),
}
}
pub fn verify_conversation_grant(
&self,
token: &str,
now_unix_ms: u64,
) -> Result<Principal, PrincipalError> {
let (claims_b64, sig_b64) = token.split_once('.').ok_or_else(|| {
tracing::warn!("conversation grant token malformed: no `.` separator");
PrincipalError::InvalidGrant
})?;
let canonical = URL_SAFE_NO_PAD.decode(claims_b64).map_err(|_| {
tracing::warn!("conversation grant token malformed: claims segment not base64");
PrincipalError::InvalidGrant
})?;
let signature = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| {
tracing::warn!("conversation grant token malformed: signature segment not base64");
PrincipalError::InvalidGrant
})?;
let claims: GrantClaims = serde_json::from_slice(&canonical).map_err(|_| {
tracing::warn!("conversation grant token malformed: claims did not decode as JSON");
PrincipalError::InvalidGrant
})?;
if claims.issuer != TurnReadRole::ISSUER
|| !self.turn_read_trust.verify_turn_read_capability(
&claims.key_id,
&canonical,
&signature,
)
{
tracing::warn!("conversation grant token signature invalid");
return Err(PrincipalError::InvalidGrant);
}
if claims.kind != GRANT_KIND {
tracing::warn!(kind = %claims.kind, "conversation grant token kind tag mismatch");
return Err(PrincipalError::InvalidGrant);
}
if now_unix_ms > claims.expires_at_ms {
tracing::warn!("conversation grant token expired");
return Err(PrincipalError::GrantExpired);
}
Ok(Principal::ConversationGrant(ConversationGrantPrincipal {
conversation_id: claims.conversation_id,
subject: claims.subject,
}))
}
pub async fn scope_for(&self, principal: &Principal) -> Result<ScopedQuery, PrincipalError> {
let scoping = match principal {
Principal::Admin(admin) => Scoping {
scope: QueryScope::Fleet,
allow_explain: true,
caller_identity: Some(admin.persona_id().to_owned()),
conversation_id: None,
turn_id: None,
web_session_id: None,
},
Principal::ConversationGrant(grant) => {
Scoping::for_conversation(grant.conversation_id(), grant.subject())
}
Principal::Persona(persona) => {
let Some(persona_host) = self.persona.load_full() else {
return Err(PrincipalError::StoreUnavailable);
};
let participations = persona_host
.participations(persona.persona_id().to_owned())
.await
.map_err(|_store_error| PrincipalError::StoreUnavailable)?;
let conversation_ids = participations
.into_iter()
.map(|participation| participation.conversation_id)
.collect();
Scoping {
scope: QueryScope::Conversations(conversation_ids),
allow_explain: false,
caller_identity: Some(persona.persona_id().to_owned()),
conversation_id: None,
turn_id: None,
web_session_id: None,
}
}
};
Ok(self.session(scoping))
}
pub async fn own_rows_scope(&self, persona_id: &str) -> Result<ScopedQuery, PrincipalError> {
let Some(persona_host) = self.persona.load_full() else {
return Err(PrincipalError::StoreUnavailable);
};
let participations = persona_host
.participations(persona_id.to_owned())
.await
.map_err(|_store_error| PrincipalError::StoreUnavailable)?;
let conversation_ids = participations
.into_iter()
.map(|participation| participation.conversation_id)
.collect();
let scoping = Scoping {
scope: QueryScope::Conversations(conversation_ids),
allow_explain: false,
caller_identity: Some(persona_id.to_owned()),
conversation_id: None,
turn_id: None,
web_session_id: None,
};
Ok(self.session(scoping))
}
#[must_use]
pub fn scope_for_turn(&self, conversation_id: &str, turn_id: &str) -> ScopedQuery {
self.session(Scoping::for_conversation(
conversation_id,
&GrantSubject::Turn(turn_id.to_owned()),
))
}
pub async fn resolve_search_scope(
&self,
principal_ref: &str,
conversation_id: &str,
turn_id: &str,
) -> Result<SearchScope, SearchScopeError> {
let Some(persona_host) = self.persona.load_full() else {
return Err(SearchScopeError::StoreUnavailable);
};
match persona_host.active_persona(principal_ref.to_owned()).await {
Ok(Some(_active)) => {}
Ok(None) => {
tracing::info!(
persona_id = %principal_ref,
conversation_id = %conversation_id,
turn_id = %turn_id,
"refusing search-scope resolution: persona is not active"
);
return Err(SearchScopeError::PersonaNotActive);
}
Err(_store_error) => {
return Err(SearchScopeError::StoreUnavailable);
}
}
#[cfg(test)]
let cap = self
.test_search_scope_cap
.lock()
.expect("poison")
.unwrap_or(SEARCH_SCOPE_CAP);
#[cfg(not(test))]
let cap = SEARCH_SCOPE_CAP;
let resolution = persona_host
.participation_scope(principal_ref.to_owned(), cap)
.await
.map_err(|_store_error| SearchScopeError::StoreUnavailable)?;
let mut conversation_ids = match resolution {
ScopeResolution::RefusedOverCap { count } => {
tracing::warn!(
persona_id = %principal_ref,
conversation_id = %conversation_id,
turn_id = %turn_id,
count,
cap,
"refusing search-scope resolution: participation count exceeds the search cap"
);
return Err(SearchScopeError::OverCap { count });
}
ScopeResolution::Resolved { conversation_ids } => conversation_ids,
};
conversation_ids.retain(|id| id != conversation_id);
let (conversation_ids, hash) = canonical_search_scope(conversation_ids);
Ok(SearchScope {
conversation_ids,
hash,
})
}
fn session(&self, scoping: Scoping) -> ScopedQuery {
let Scoping {
scope,
allow_explain,
caller_identity,
conversation_id,
turn_id,
web_session_id,
} = scoping;
ScopedQuery {
base_state: self.base_state.clone(),
journal: self.journal.clone(),
persona: self.persona.clone(),
dashboard: self.dashboard.clone(),
limits: self.limits.clone(),
cache: self.cache.clone(),
trusted_signers: self.trusted_signers.clone(),
handoff_trust: self.handoff_trust.clone(),
routine_catalog: self.routine_catalog.clone(),
scope,
allow_explain,
caller_identity,
conversation_id,
turn_id,
web_session_id,
#[cfg(test)]
race_inject_after_count_read: std::sync::Mutex::new(None),
}
}
}
struct Scoping {
scope: QueryScope,
allow_explain: bool,
caller_identity: Option<String>,
conversation_id: Option<String>,
turn_id: Option<String>,
web_session_id: Option<String>,
}
impl Scoping {
fn for_conversation(conversation_id: &str, subject: &GrantSubject) -> Self {
Self {
scope: QueryScope::Conversations(vec![conversation_id.to_owned()]),
allow_explain: false,
caller_identity: None,
conversation_id: Some(conversation_id.to_owned()),
turn_id: subject.turn_id().map(str::to_owned),
web_session_id: subject.web_session_id().map(str::to_owned),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ScopedQueryError {
#[error("query rejected: {0}")]
Rejected(String),
#[error("{0}")]
SourceBudgetExceeded(String),
#[error("{0}")]
UnknownTable(String),
#[error("{0}")]
UnknownColumn(String),
#[error("query timed out")]
Timeout,
#[error("query failed")]
Internal,
}
const FLEET_BUDGET_EXCEEDED_MESSAGE: &str = "this fleet-wide query exceeds this deployment's \
configured event budget; an administrator can raise the `query_max_source_events` setting \
to allow a larger scope";
const CONVERSATION_BUDGET_EXCEEDED_MESSAGE: &str = "your accessible conversation history exceeds this deployment's configured budget; an \
administrator needs to adjust retention or capacity before this query can run";
const FLEET_BYTES_BUDGET_EXCEEDED_MESSAGE: &str = "this fleet-wide query exceeds this deployment's \
configured source-data budget; an administrator can raise the `query_max_source_bytes` \
setting to allow a larger scope";
const CONVERSATION_BYTES_BUDGET_EXCEEDED_MESSAGE: &str = "your accessible conversation history \
exceeds this deployment's configured source-data budget; an administrator needs to adjust \
retention or capacity before this query can run";
const FLEET_CACHED_VOLUME_BUDGET_EXCEEDED_MESSAGE: &str = "this fleet-wide query exceeds this \
deployment's configured cached-scan volume budget; an administrator can raise the decode \
cache's configured event-volume ceiling to allow a larger scope";
const CONVERSATION_CACHED_VOLUME_BUDGET_EXCEEDED_MESSAGE: &str = "your accessible conversation \
history exceeds this deployment's configured cached-scan volume budget; an administrator \
needs to adjust the decode cache's configured event-volume ceiling before this query can \
run";
const FLEET_UNKNOWN_TABLE_MESSAGE: &str = "that table is not in this deployment's catalog; run \
`SELECT table_name FROM information_schema.tables` to see what you can query";
pub const CONVERSATION_CATALOG: &[&str] = &[
"events",
"messages",
"tool_calls",
"approvals",
"attribution",
"payments",
"handoffs",
"grant_replays",
"usage",
"model_call",
"turn_failed",
"turn_dispatch",
"refusals",
"wallet_link_lifecycle",
];
pub const OWNER_ONLY_CATALOG: &[&str] = &[
"routines",
"fires",
"routine_grants",
"routine_active_grants",
"routine_setup",
"routine_overview",
];
#[must_use]
pub fn catalog_sentence(names: &[&str]) -> String {
match names {
[] => String::new(),
[only] => (*only).to_owned(),
[first, second] => format!("{first} and {second}"),
[leading @ .., last] => format!("{}, and {last}", leading.join(", ")),
}
}
const MAX_ADVERTISED_COLUMNS: usize = 32;
fn column_list_sentence(columns: &[String]) -> String {
if columns.len() <= MAX_ADVERTISED_COLUMNS {
let borrowed: Vec<&str> = columns.iter().map(String::as_str).collect();
return catalog_sentence(&borrowed);
}
let named = columns[..MAX_ADVERTISED_COLUMNS].join(", ");
let remaining = columns.len() - MAX_ADVERTISED_COLUMNS;
format!("{named}, and {remaining} more")
}
fn unknown_column_message(unresolved: &crate::engine::UnresolvedColumn) -> String {
let requested = &unresolved.name;
let written = unresolved.qualifier.as_ref().map_or_else(
|| requested.clone(),
|qualifier| format!("{qualifier}.{requested}"),
);
let mut relations: Vec<&str> = Vec::new();
for relation in unresolved
.valid_fields
.iter()
.filter_map(|(relation, _)| relation.as_deref())
{
if !relations.contains(&relation) {
relations.push(relation);
}
}
let sole_relation = match relations.as_slice() {
[only] => Some(*only),
_ => None,
};
let anchor = unresolved
.qualifier
.as_deref()
.map_or(sole_relation, |qualifier| {
relations.contains(&qualifier).then_some(qualifier)
});
if let Some(anchor) = anchor {
let columns: Vec<String> = unresolved
.valid_fields
.iter()
.filter(|(relation, _)| relation.as_deref() == Some(anchor))
.map(|(_, column)| column.clone())
.collect();
if !columns.is_empty() {
return format!(
"there is no `{requested}` column on `{anchor}`; you can select {}",
column_list_sentence(&columns)
);
}
}
let columns: Vec<String> = unresolved
.valid_fields
.iter()
.map(|(relation, column)| {
relation
.as_ref()
.map_or_else(|| column.clone(), |relation| format!("{relation}.{column}"))
})
.collect();
if columns.is_empty() {
return format!(
"there is no `{written}` column here; add a `FROM` clause naming the table to read it \
from"
);
}
if unresolved.qualifier.is_some() {
let quoted: Vec<String> = relations.iter().map(|name| format!("`{name}`")).collect();
let borrowed: Vec<&str> = quoted.iter().map(String::as_str).collect();
return format!(
"there is no `{written}` column available where you used it; that part of the query \
reads from {}, so you can select {}",
catalog_sentence(&borrowed),
column_list_sentence(&columns)
);
}
format!(
"there is no `{requested}` column available where you used it; you can select {}",
column_list_sentence(&columns)
)
}
static CONVERSATION_UNKNOWN_TABLE_MESSAGE: LazyLock<String> = LazyLock::new(|| {
format!(
"that table is not in this conversation's catalog; you can query {}",
catalog_sentence(CONVERSATION_CATALOG)
)
});
static OWNER_UNKNOWN_TABLE_MESSAGE: LazyLock<String> = LazyLock::new(|| {
let tables: Vec<&str> = CONVERSATION_CATALOG
.iter()
.chain(OWNER_ONLY_CATALOG.iter())
.copied()
.collect();
format!(
"that table is not in this account's catalog; you can query {}",
catalog_sentence(&tables)
)
});
#[derive(Debug, Clone, Copy)]
enum ReplayError {
Internal,
BytesBudgetExceeded {
partitions_replayed: usize,
bytes_read: u64,
},
}
struct ResolvedPartitions {
tables: Vec<PartitionTables>,
skipped_partitions: usize,
replayed_events: usize,
replayed_bytes: u64,
cached_volume_events: usize,
cached_volume_bytes: u64,
}
pub struct ScopedQuery {
base_state: SessionState,
journal: Arc<dyn PartitionJournal>,
persona: PersonaAccess,
dashboard: DashboardCell,
limits: QueryLimits,
cache: Arc<DecodeCache>,
trusted_signers: Vec<Vec<u8>>,
handoff_trust: RoleTrustSet<HandoffRole>,
routine_catalog: Option<Arc<dyn RoutineCatalog>>,
scope: QueryScope,
allow_explain: bool,
caller_identity: Option<String>,
conversation_id: Option<String>,
turn_id: Option<String>,
web_session_id: Option<String>,
#[cfg(test)]
race_inject_after_count_read: std::sync::Mutex<Option<(String, Vec<polyc_eventlog::Event>)>>,
}
impl std::fmt::Debug for ScopedQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ScopedQuery")
.field("caller_identity", &self.caller_identity)
.field("conversation_id", &self.conversation_id)
.field("turn_id", &self.turn_id)
.field("web_session_id", &self.web_session_id)
.finish_non_exhaustive()
}
}
impl ScopedQuery {
#[must_use]
pub fn caller_identity(&self) -> Option<&str> {
self.caller_identity.as_deref()
}
#[must_use]
pub fn conversation_id(&self) -> Option<&str> {
self.conversation_id.as_deref()
}
#[must_use]
pub fn turn_id(&self) -> Option<&str> {
self.turn_id.as_deref()
}
#[must_use]
pub fn web_session_id(&self) -> Option<&str> {
self.web_session_id.as_deref()
}
pub async fn execute(&self, sql: &str) -> Result<QueryResultJson, ScopedQueryError> {
self.execute_with_params(sql, &[]).await
}
pub async fn execute_with_params(
&self,
sql: &str,
params: &[&str],
) -> Result<QueryResultJson, ScopedQueryError> {
if let Err(rejected) = statement_gate::check_statement_allowed(sql, self.allow_explain) {
return Err(ScopedQueryError::Rejected(rejected.to_string()));
}
let body = async {
let scope_label = self.scope_label();
let estimated_source_events = self
.estimate_source_event_total()
.await
.map_err(|err| self.map_replay_error(err, scope_label))?;
self.enforce_source_budget(estimated_source_events, scope_label)?;
let resolved = self
.resolve_partitions()
.await
.map_err(|err| self.map_replay_error(err, scope_label))?;
crate::metrics::record_query_observed(
scope_label,
resolved.replayed_events,
resolved.replayed_bytes,
);
crate::metrics::record_cached_scan_volume(
scope_label,
resolved.cached_volume_events,
resolved.cached_volume_bytes,
);
self.enforce_source_budget(resolved.replayed_events, scope_label)?;
if self.cache.enabled() {
self.enforce_cached_volume_budget(resolved.cached_volume_events, scope_label)?;
}
let owner_persona_id = match &self.scope {
QueryScope::Fleet => None,
QueryScope::Conversations(_) => self.caller_identity.as_deref(),
};
let reference = self
.resolve_reference_data(&resolved.tables, owner_persona_id)
.await;
let engine = QueryEngine::build_from_tables(
&self.base_state,
&self.scope,
resolved.tables,
reference,
self.limits.clone(),
owner_persona_id.is_some(),
)
.await
.map_err(|err| {
tracing::error!(error = %err, "failed to build the scoped query engine");
ScopedQueryError::Internal
})?;
let output = engine
.execute_with_params(sql, params, self.allow_explain)
.await
.map_err(|err| {
if crate::engine::is_unresolved_table_error(&err) {
tracing::warn!(
error = %err,
scope = scope_label,
"query named a table this scope's catalog does not carry"
);
return ScopedQueryError::UnknownTable(
self.unknown_table_message(owner_persona_id.is_some())
.to_owned(),
);
}
if let Some(unresolved) = crate::engine::unresolved_column(&err) {
tracing::warn!(
error = %err,
scope = scope_label,
"query named a column the tables it selects from do not carry"
);
return ScopedQueryError::UnknownColumn(unknown_column_message(
&unresolved,
));
}
tracing::error!(error = %err, "query execution failed");
ScopedQueryError::Internal
})?;
output::output_to_json(&output, resolved.skipped_partitions).map_err(|err| {
tracing::error!(error = %err, "failed to encode query result as JSON");
ScopedQueryError::Internal
})
};
match tokio::time::timeout(self.limits.timeout, body).await {
Ok(result) => result,
Err(_elapsed) => {
tracing::error!(timeout = ?self.limits.timeout, "query exceeded its timeout");
Err(ScopedQueryError::Timeout)
}
}
}
const fn scope_label(&self) -> &'static str {
match &self.scope {
QueryScope::Fleet => "fleet",
QueryScope::Conversations(_) => {
if self.conversation_id.is_some() {
"grant"
} else if self.caller_identity.is_some() {
"persona"
} else {
"conversations"
}
}
}
}
fn enforce_source_budget(
&self,
total_events: usize,
scope_label: &'static str,
) -> Result<(), ScopedQueryError> {
if total_events <= self.limits.max_source_events {
return Ok(());
}
tracing::error!(
total_events,
max_source_events = self.limits.max_source_events,
scope = scope_label,
"scoped query's replayed source data exceeded the pre-execution decode-amplification \
budget; refusing to decode"
);
crate::metrics::record_source_budget_exceeded(scope_label);
let message = match &self.scope {
QueryScope::Fleet => FLEET_BUDGET_EXCEEDED_MESSAGE,
QueryScope::Conversations(_) => CONVERSATION_BUDGET_EXCEEDED_MESSAGE,
};
Err(ScopedQueryError::SourceBudgetExceeded(message.to_owned()))
}
fn unknown_table_message(&self, owner_scoped_routines: bool) -> &'static str {
match &self.scope {
QueryScope::Fleet => FLEET_UNKNOWN_TABLE_MESSAGE,
QueryScope::Conversations(_) if owner_scoped_routines => {
OWNER_UNKNOWN_TABLE_MESSAGE.as_str()
}
QueryScope::Conversations(_) => CONVERSATION_UNKNOWN_TABLE_MESSAGE.as_str(),
}
}
fn enforce_cached_volume_budget(
&self,
total_cached_volume_events: usize,
scope_label: &'static str,
) -> Result<(), ScopedQueryError> {
if total_cached_volume_events <= self.cache.max_cached_source_events() {
return Ok(());
}
tracing::error!(
total_cached_volume_events,
max_cached_source_events = self.cache.max_cached_source_events(),
scope = scope_label,
"scoped query's effective cached-scan volume exceeded the pre-execution cached-scan \
volume budget; refusing to hand it to the query engine"
);
crate::metrics::record_cached_volume_budget_exceeded(scope_label);
let message = match &self.scope {
QueryScope::Fleet => FLEET_CACHED_VOLUME_BUDGET_EXCEEDED_MESSAGE,
QueryScope::Conversations(_) => CONVERSATION_CACHED_VOLUME_BUDGET_EXCEEDED_MESSAGE,
};
Err(ScopedQueryError::SourceBudgetExceeded(message.to_owned()))
}
fn map_replay_error(&self, err: ReplayError, scope_label: &'static str) -> ScopedQueryError {
match err {
ReplayError::Internal => ScopedQueryError::Internal,
ReplayError::BytesBudgetExceeded {
partitions_replayed,
bytes_read,
} => {
tracing::debug!(
partitions_replayed,
scope = scope_label,
"translating a byte-budget replay abort to the caller-facing rejection"
);
crate::metrics::record_query_replayed_bytes(scope_label, bytes_read);
crate::metrics::record_source_budget_exceeded(scope_label);
let message = match &self.scope {
QueryScope::Fleet => FLEET_BYTES_BUDGET_EXCEEDED_MESSAGE,
QueryScope::Conversations(_) => CONVERSATION_BYTES_BUDGET_EXCEEDED_MESSAGE,
};
ScopedQueryError::SourceBudgetExceeded(message.to_owned())
}
}
}
async fn estimate_source_event_total(&self) -> Result<usize, ReplayError> {
let partition_names: Vec<String> = match &self.scope {
QueryScope::Fleet => self
.journal
.list_partitions()
.await
.map_err(|err| {
tracing::error!(
error = %err,
"failed to list partitions while estimating a fleet query's pre-decode \
source-event total"
);
ReplayError::Internal
})?
.into_iter()
.filter(|partition| is_admitted_partition(partition))
.collect(),
QueryScope::Conversations(conversation_ids) => conversation_ids
.iter()
.map(|conversation_id| format!("conv-{conversation_id}"))
.collect(),
};
let mut skipped = 0_usize;
let mut total: u64 = 0;
for partition in partition_names {
match self.journal.partition_event_count(partition.clone()).await {
Ok(count) => total = total.saturating_add(count),
Err(err) => {
self.handle_skippable_partition_error(
&err,
&partition,
"read the conversation's own partition event count while estimating its \
pre-decode source-event total",
&mut skipped,
)?;
}
}
}
Ok(usize::try_from(total).unwrap_or(usize::MAX))
}
fn handle_skippable_partition_error(
&self,
err: &JournalError,
partition: &str,
context: &str,
skipped_partitions: &mut usize,
) -> Result<(), ReplayError> {
if matches!(self.scope, QueryScope::Conversations(_))
&& partition == ROUTINE_SCHEDULER_PARTITION
{
tracing::warn!(
error = %err,
partition = %partition,
"skipping the unreadable/not-yet-existing routine scheduler partition for a \
persona-scoped query"
);
return Ok(());
}
if matches!(self.scope, QueryScope::Fleet) {
tracing::warn!(
error = %err,
partition = %partition,
"skipping unreadable partition for a fleet query"
);
*skipped_partitions += 1;
return Ok(());
}
tracing::error!(
error = %err,
partition = %partition,
"failed to {}",
context
);
Err(ReplayError::Internal)
}
#[cfg(test)]
async fn fire_race_test_hook(&self, partition: &str) -> Result<(), ReplayError> {
let injected = {
let mut slot = self.race_inject_after_count_read.lock().expect("poison");
match slot.as_ref() {
Some((target, _)) if target == partition => slot.take(),
_ => None,
}
};
let Some((target, events)) = injected else {
return Ok(());
};
self.journal
.append_batch(target, events)
.await
.map_err(|err| {
tracing::error!(error = %err, partition = %partition, "test race injection append failed");
ReplayError::Internal
})?;
Ok(())
}
fn cached_bytes_budget_exceeded(
partitions_already_resolved: usize,
partition: &str,
replayed_bytes: u64,
max_bytes: u64,
context: &'static str,
) -> ReplayError {
let partitions_replayed = partitions_already_resolved + 1;
tracing::error!(
bytes_read = replayed_bytes,
max_source_bytes = max_bytes,
partition = %partition,
partitions_replayed,
"cache-aware query's replayed source bytes exceeded the pre-execution byte budget \
{}; aborting before the rest of this scope is read",
context
);
ReplayError::BytesBudgetExceeded {
partitions_replayed,
bytes_read: replayed_bytes,
}
}
async fn resolve_partitions(&self) -> Result<ResolvedPartitions, ReplayError> {
if self.cache.enabled() {
self.resolve_partitions_cached().await
} else {
self.resolve_partitions_uncached().await
}
}
async fn resolve_partitions_uncached(&self) -> Result<ResolvedPartitions, ReplayError> {
let (partitions, skipped_partitions) = self.replay_scoped_partitions().await?;
let replayed_events: usize = partitions.iter().map(|p| p.events.len()).sum();
let replayed_bytes: u64 = partitions
.iter()
.flat_map(|p| &p.events)
.map(|(_position, event)| event.payload.len() as u64)
.sum();
let tables = partitions
.iter()
.map(|partition| {
decode_partition_tables(
&partition.partition,
&partition.events,
&self.trusted_signers,
&self.handoff_trust,
)
.map_err(|err| {
tracing::error!(
error = %err,
partition = %partition.partition,
"failed to decode a replayed partition"
);
ReplayError::Internal
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(ResolvedPartitions {
tables,
skipped_partitions,
replayed_events,
replayed_bytes,
cached_volume_events: replayed_events,
cached_volume_bytes: replayed_bytes,
})
}
#[allow(
clippy::too_many_lines,
reason = "one cohesive per-partition resolution loop; the Hit/Tail/Miss arms share \
watermark derivation and are clearer inline than split, mirroring \
replay_scoped_partitions's own allow"
)]
async fn resolve_partitions_cached(&self) -> Result<ResolvedPartitions, ReplayError> {
let max_bytes = self.limits.max_source_bytes;
let mut tables = Vec::new();
let mut skipped_partitions = 0_usize;
let mut replayed_events = 0_usize;
let mut replayed_bytes: u64 = 0;
let mut cached_volume_events: u64 = 0;
let partition_names: Vec<String> = match &self.scope {
QueryScope::Fleet => self
.journal
.list_partitions()
.await
.map_err(|err| {
tracing::error!(error = %err, "failed to list partitions for a fleet query");
ReplayError::Internal
})?
.into_iter()
.filter(|partition| is_admitted_partition(partition))
.collect(),
QueryScope::Conversations(conversation_ids) => {
let mut names: Vec<String> = conversation_ids
.iter()
.map(|conversation_id| format!("conv-{conversation_id}"))
.collect();
if self.caller_identity.is_some() {
names.push(ROUTINE_SCHEDULER_PARTITION.to_owned());
}
names
}
};
for partition in partition_names {
let count = match self.journal.partition_event_count(partition.clone()).await {
Ok(count) => count,
Err(err) => {
self.handle_skippable_partition_error(
&err,
&partition,
"read the conversation's own partition event count",
&mut skipped_partitions,
)?;
continue;
}
};
#[cfg(test)]
self.fire_race_test_hook(&partition).await?;
let (resolved_tables, watermark) = match self.cache.lookup(&partition, count) {
Lookup::Hit(cached) => (cached, count),
Lookup::Tail { base, from } => {
let remaining = max_bytes.saturating_sub(replayed_bytes);
let bounded = match self
.journal
.replay_from_with_positions_bounded(partition.clone(), from, remaining)
.await
{
Ok(bounded) => bounded,
Err(err) => {
self.handle_skippable_partition_error(
&err,
&partition,
"replay the conversation's own partition tail",
&mut skipped_partitions,
)?;
continue;
}
};
replayed_events += bounded.events.len();
replayed_bytes = replayed_bytes.saturating_add(bounded.bytes_read);
let budget_exceeded = bounded.budget_exceeded;
let watermark = bounded
.events
.last()
.map_or(from, |(position, _)| position + 1);
let tail_tables = decode_partition_tables(
&partition,
&bounded.events,
&self.trusted_signers,
&self.handoff_trust,
)
.map_err(|err| {
tracing::error!(
error = %err,
partition = %partition,
"failed to decode a partition's replayed tail"
);
ReplayError::Internal
})?;
let merged = base.concat(&tail_tables).map_err(|err| {
tracing::error!(
error = %err,
partition = %partition,
"failed to merge a partition's cached tables with its replayed tail"
);
ReplayError::Internal
})?;
self.cache.store_tail(&partition, watermark, merged.clone());
if budget_exceeded {
return Err(Self::cached_bytes_budget_exceeded(
tables.len(),
&partition,
replayed_bytes,
max_bytes,
"mid-tail-replay",
));
}
(merged, watermark)
}
Lookup::Miss => {
let remaining = max_bytes.saturating_sub(replayed_bytes);
let bounded = match self
.journal
.replay_with_positions_bounded(partition.clone(), remaining)
.await
{
Ok(bounded) => bounded,
Err(err) => {
self.handle_skippable_partition_error(
&err,
&partition,
"replay the conversation's own partition",
&mut skipped_partitions,
)?;
continue;
}
};
replayed_events += bounded.events.len();
replayed_bytes = replayed_bytes.saturating_add(bounded.bytes_read);
let budget_exceeded = bounded.budget_exceeded;
let watermark = bounded
.events
.last()
.map_or(0, |(position, _)| position + 1);
let fresh = decode_partition_tables(
&partition,
&bounded.events,
&self.trusted_signers,
&self.handoff_trust,
)
.map_err(|err| {
tracing::error!(
error = %err,
partition = %partition,
"failed to decode a freshly-replayed partition"
);
ReplayError::Internal
})?;
self.cache.store_full(&partition, watermark, fresh.clone());
if budget_exceeded {
return Err(Self::cached_bytes_budget_exceeded(
tables.len(),
&partition,
replayed_bytes,
max_bytes,
"mid-replay",
));
}
(fresh, watermark)
}
};
cached_volume_events = cached_volume_events.saturating_add(watermark);
tables.push(resolved_tables);
}
let cached_volume_bytes: u64 = tables.iter().map(|t| t.memory_bytes() as u64).sum();
Ok(ResolvedPartitions {
tables,
skipped_partitions,
replayed_events,
replayed_bytes,
cached_volume_events: usize::try_from(cached_volume_events).unwrap_or(usize::MAX),
cached_volume_bytes,
})
}
#[allow(
clippy::too_many_lines,
reason = "one cohesive replay routine; the Fleet and Conversations arms share the \
byte-budget/early-abort accounting and are clearer inline than split"
)]
async fn replay_scoped_partitions(&self) -> Result<(Vec<PartitionEvents>, usize), ReplayError> {
let max_bytes = self.limits.max_source_bytes;
let mut bytes_read: u64 = 0;
match &self.scope {
QueryScope::Fleet => {
let partition_names = self.journal.list_partitions().await.map_err(|err| {
tracing::error!(error = %err, "failed to list partitions for a fleet query");
ReplayError::Internal
})?;
let mut partitions = Vec::new();
let mut skipped = 0_usize;
for partition in partition_names {
if !is_admitted_partition(&partition) {
continue;
}
let remaining = max_bytes.saturating_sub(bytes_read);
match self
.journal
.replay_with_positions_bounded(partition.clone(), remaining)
.await
{
Ok(bounded) => {
bytes_read = bytes_read.saturating_add(bounded.bytes_read);
let budget_exceeded = bounded.budget_exceeded;
partitions.push(PartitionEvents {
partition: partition.clone(),
events: bounded.events,
});
if budget_exceeded {
let partitions_replayed = partitions.len();
tracing::error!(
bytes_read,
max_source_bytes = max_bytes,
partition = %partition,
partitions_replayed,
"fleet query's replayed source bytes exceeded the \
pre-execution byte budget mid-replay; aborting before the \
rest of the deployment is read"
);
return Err(ReplayError::BytesBudgetExceeded {
partitions_replayed,
bytes_read,
});
}
}
Err(err) => {
tracing::warn!(
error = %err,
partition = %partition,
"skipping unreadable partition for a fleet query"
);
skipped += 1;
}
}
}
Ok((partitions, skipped))
}
QueryScope::Conversations(conversation_ids) => {
let mut partitions = Vec::with_capacity(conversation_ids.len());
for conversation_id in conversation_ids {
let partition = format!("conv-{conversation_id}");
let remaining = max_bytes.saturating_sub(bytes_read);
let bounded = self
.journal
.replay_with_positions_bounded(partition.clone(), remaining)
.await
.map_err(|err| {
tracing::error!(
error = %err,
conversation_id = %conversation_id,
"failed to replay the conversation's own partition"
);
ReplayError::Internal
})?;
bytes_read = bytes_read.saturating_add(bounded.bytes_read);
let budget_exceeded = bounded.budget_exceeded;
partitions.push(PartitionEvents {
partition: partition.clone(),
events: bounded.events,
});
if budget_exceeded {
let partitions_replayed = partitions.len();
tracing::error!(
bytes_read,
max_source_bytes = max_bytes,
partition = %partition,
partitions_replayed,
conversations_in_scope = conversation_ids.len(),
"conversation-scoped query's replayed source bytes exceeded the \
pre-execution byte budget mid-replay; aborting before the rest of \
this scope is read"
);
return Err(ReplayError::BytesBudgetExceeded {
partitions_replayed,
bytes_read,
});
}
}
if self.caller_identity.is_some() {
let remaining = max_bytes.saturating_sub(bytes_read);
match self
.journal
.replay_with_positions_bounded(
ROUTINE_SCHEDULER_PARTITION.to_owned(),
remaining,
)
.await
{
Ok(bounded) => {
bytes_read = bytes_read.saturating_add(bounded.bytes_read);
let budget_exceeded = bounded.budget_exceeded;
partitions.push(PartitionEvents {
partition: ROUTINE_SCHEDULER_PARTITION.to_owned(),
events: bounded.events,
});
if budget_exceeded {
let partitions_replayed = partitions.len();
tracing::error!(
bytes_read,
max_source_bytes = max_bytes,
partition = ROUTINE_SCHEDULER_PARTITION,
partitions_replayed,
"persona-scoped query's replayed source bytes exceeded the \
pre-execution byte budget reading the routine scheduler's \
own partition; aborting"
);
return Err(ReplayError::BytesBudgetExceeded {
partitions_replayed,
bytes_read,
});
}
}
Err(err) => {
tracing::warn!(
error = %err,
partition = ROUTINE_SCHEDULER_PARTITION,
"skipping the unreadable/not-yet-existing routine scheduler \
partition for a persona-scoped query"
);
}
}
}
Ok((partitions, 0))
}
}
}
async fn resolve_reference_data(
&self,
partition_tables: &[PartitionTables],
owner_persona_id: Option<&str>,
) -> ReferenceData {
if !matches!(self.scope, QueryScope::Fleet) {
return ReferenceData::empty_except_routines(
self.resolve_routines(owner_persona_id).await,
);
}
let dashboard_rows = self.dashboard.rows();
let Some(persona) = self.persona.load_full() else {
tracing::warn!(
"persona host unavailable; the fleet query's persona reference tables build \
empty"
);
return ReferenceData::empty_except_dashboard(dashboard_rows);
};
let mut candidate_ids = std::collections::BTreeSet::new();
for tables in partition_tables {
let persona_id_column = tables
.attribution_raw
.column_by_name("persona_id")
.and_then(|column| column.as_any().downcast_ref::<arrow::array::StringArray>());
let Some(persona_id_column) = persona_id_column else {
continue;
};
for persona_id in persona_id_column.iter().flatten() {
if !persona_id.is_empty() {
candidate_ids.insert(persona_id.to_owned());
}
}
}
match persona.usage_rollup_index().await {
Ok(ids) => candidate_ids.extend(ids),
Err(err) => {
tracing::warn!(
error = %err,
"usage-rollup-index union unavailable for a fleet query; candidate set \
falls back to attribution events only"
);
}
}
let mut seen_canonical = std::collections::BTreeSet::new();
let mut personas = Vec::new();
let mut participations = Vec::new();
let mut wallets = Vec::new();
let mut spend_policies = Vec::new();
let mut credentials = Vec::new();
let mut usage_rollups = Vec::new();
for candidate in candidate_ids {
let snapshot = match persona.reference_snapshot(candidate.clone()).await {
Ok(Some(snapshot)) => snapshot,
Ok(None) => continue,
Err(err) => {
tracing::warn!(
error = %err,
persona_id = %candidate,
"skipping an unreadable persona's reference snapshot for a fleet query"
);
continue;
}
};
let persona_id = snapshot.profile.persona_id.clone();
if !seen_canonical.insert(persona_id.clone()) {
continue;
}
participations.extend(
snapshot
.participations
.into_iter()
.map(|participation| (persona_id.clone(), participation)),
);
if let Some(wallet) = snapshot.wallet_link {
wallets.push((persona_id.clone(), wallet));
}
if let Some(policy) = snapshot.spend_policy {
spend_policies.push((persona_id.clone(), policy));
}
if let Some(credential) = snapshot.credential {
credentials.push((persona_id.clone(), credential));
}
if let Some(usage_rollup) = snapshot.usage_rollup {
usage_rollups.push((persona_id.clone(), usage_rollup));
}
personas.push(snapshot.profile);
}
let routines = self.resolve_routines(owner_persona_id).await;
ReferenceData {
personas,
participations,
wallets,
spend_policies,
credentials,
usage_rollups,
routines,
dashboard: dashboard_rows,
}
}
async fn resolve_routines(
&self,
owner_persona_id: Option<&str>,
) -> Vec<crate::routine_catalog::RoutineStatusRecord> {
let Some(catalog) = &self.routine_catalog else {
return Vec::new();
};
let routines = match catalog.list_routines().await {
Ok(routines) => routines,
Err(err) => {
tracing::warn!(
error = %err,
"routine catalog unavailable; the query's routines table builds empty"
);
Vec::new()
}
};
match owner_persona_id {
Some(persona_id) => routines
.into_iter()
.filter(|routine| routine.creator_persona == persona_id)
.collect(),
None => routines,
}
}
}
#[cfg(test)]
mod tests;