#![warn(missing_docs)]
use futures_util::stream::{self, StreamExt, TryStreamExt};
use opentelemetry::metrics::{Counter, Histogram};
use opentelemetry::KeyValue;
use skilj_core::access_control::{AccessLevel, JwksCache, RevocationBroadcaster, Role};
use skilj_core::db::Pool;
use skilj_core::event_cache::EventCache;
use skilj_core::event_store::{Error as EventStoreError, Event, EventBroadcaster};
use skilj_core::plugin::{BoundedContextEvent, CommandDispatcher};
use skilj_core::projections::{ProjectionRebuildStatus, ProjectionRegistration};
use skilj_core::shared::CommandDecision;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use std::sync::LazyLock;
use tracing::Instrument;
static BACKGROUND_TASK_TICK_DURATION: LazyLock<Histogram<f64>> = LazyLock::new(|| {
opentelemetry::global::meter("skilj")
.f64_histogram("skilj.background_task.tick.duration")
.with_unit("s")
.with_description("Duration of one tick of a skilj background task.")
.build()
});
static BACKGROUND_TASK_ERRORS: LazyLock<Counter<u64>> = LazyLock::new(|| {
opentelemetry::global::meter("skilj")
.u64_counter("skilj.background_task.errors")
.with_description("Errors encountered by a skilj background task.")
.build()
});
const BACKGROUND_TASK_CONCURRENCY: usize = 16;
pub const MIN_POOL_CONNECTIONS: u32 = 2;
pub fn default_pool_options() -> PgPoolOptions {
PgPoolOptions::new().test_before_acquire(false)
}
pub const DEFAULT_IDEMPOTENCY_KEY_RETENTION: std::time::Duration =
std::time::Duration::from_secs(48 * 60 * 60);
const IDEMPOTENCY_KEY_CLEANUP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
const IDEMPOTENCY_KEY_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_secs(60 * 60);
const IDEMPOTENCY_KEY_CLEANUP_BATCH: i64 = 10_000;
pub const DEFAULT_DEADLINE_RETENTION: std::time::Duration =
std::time::Duration::from_secs(30 * 24 * 60 * 60);
const DEADLINE_CLEANUP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
async fn contain_panic(
task: &'static str,
unit: String,
work: impl std::future::Future<Output = ()>,
) {
use futures_util::FutureExt;
if let Err(panic) = std::panic::AssertUnwindSafe(work).catch_unwind().await {
let message = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic payload".to_string());
tracing::error!(task, unit = %unit, panic = %message, "background task unit panicked");
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", task),
KeyValue::new("reason", "panicked"),
],
);
}
}
pub use inventory;
pub mod dev;
#[cfg(feature = "local")]
pub mod local;
pub use skilj_core::access_control::{IdpConfig, SigningAlgorithm};
pub use skilj_core::blind::{normalize, BlindIndexKey};
pub use skilj_core::db::PgPoolOptions;
pub use skilj_core::encryption::provider::BoxFuture;
pub use skilj_core::encryption::{
EncryptionMasterKey, InMemoryKeyProvider, KeyFileProvider, KeyProvider, KeyService,
KeyVersions, VaultTransitProvider, WrappedDataKey,
};
pub use skilj_core::plugin::{
requires_role, BlindErasure, BlindTag, CancelDeadline, CommandType, CrossContextRoute,
EventType, MissedRunPolicy, Projection, RecurrenceSpec, ScheduleDeadline, Snapshot,
DEFAULT_BOUNDED_CONTEXT,
};
pub use skilj_graphql::federation::FederationOptions;
pub use skilj_graphql::limits::GraphqlLimits;
pub use skilj_macros::auto_register;
struct IdentityProvider {
config: IdpConfig,
cache: Arc<JwksCache>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ShutdownReport {
pub stopped: Vec<&'static str>,
pub aborted: Vec<&'static str>,
pub pool_closed: bool,
}
#[derive(Clone)]
struct Background {
stop: Arc<tokio::sync::watch::Sender<bool>>,
tasks: Arc<std::sync::Mutex<Vec<NamedTask>>>,
}
type NamedTask = (&'static str, tokio::task::JoinHandle<()>);
impl Background {
fn new() -> Self {
Self {
stop: Arc::new(tokio::sync::watch::channel(false).0),
tasks: Arc::default(),
}
}
fn spawn<F, Fut>(&self, name: &'static str, task: F)
where
F: FnOnce(StopSignal) -> Fut,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
let handle = tokio::spawn(task(StopSignal(self.stop.subscribe())));
self.tasks
.lock()
.expect("the background task list is never poisoned")
.push((name, handle));
}
}
struct StopSignal(tokio::sync::watch::Receiver<bool>);
impl StopSignal {
async fn stopped(&mut self) {
if self.0.wait_for(|stop| *stop).await.is_err() {
std::future::pending::<()>().await;
}
}
async fn sleep(&mut self, duration: std::time::Duration) -> bool {
tokio::select! {
() = self.stopped() => true,
() = tokio::time::sleep(duration) => false,
}
}
}
pub struct Skilj {
pool: Pool,
command_types: Arc<HashMap<(String, String), RegisteredCommandType>>,
projections: Arc<HashMap<(String, String), RegisteredProjection>>,
event_types: Arc<HashMap<(String, String), RegisteredEventType>>,
snapshots: Arc<HashMap<(String, String), RegisteredSnapshot>>,
bootstrap: skilj_core::bootstrap::BootstrapGate,
parked_delivery_retry_permits: Arc<tokio::sync::Semaphore>,
identity_provider: Option<Arc<IdentityProvider>>,
projection_query_wait_timeout: std::time::Duration,
read_cursor_checkout_lease: std::time::Duration,
max_events_per_read: usize,
graphql_limits: skilj_graphql::limits::GraphqlLimits,
graphql_federation: Option<Arc<skilj_graphql::federation::FederationOptions>>,
graphql_naming: skilj_graphql::naming::Naming,
key_service: Option<KeyService>,
event_broadcaster: EventBroadcaster,
revocation_broadcaster: RevocationBroadcaster,
event_cache: EventCache,
schema_registry: Arc<skilj_graphql::schema::SchemaRegistry>,
template_cache: skilj_core::template_cache::TemplateCache,
command_batcher: skilj_core::command_batcher::CommandBatcher,
blind_index: skilj_core::blind::BlindIndex,
background: Background,
}
struct Dispatcher {
command_types: Arc<HashMap<(String, String), RegisteredCommandType>>,
template_cache: skilj_core::template_cache::TemplateCache,
snapshots: SnapshotDispatcherImpl,
blind_index: skilj_core::blind::BlindIndex,
}
impl CommandDispatcher for Dispatcher {
fn blind_index(&self) -> skilj_core::blind::BlindIndex {
self.blind_index.clone()
}
fn dispatch(
&self,
bounded_context: &str,
command_type: &str,
payload: &str,
matching_events: &[Event],
) -> Option<skilj_core::error::Result<CommandDecision>> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.command_types
.get(&(bounded_context, command_type.to_string()))?;
Some((registered.decide)(
payload,
matching_events,
&[],
&skilj_core::plugin::DecisionContext::at(chrono::Utc::now()),
))
}
fn dispatch_with(
&self,
bounded_context: &str,
command_type: &str,
payload: &str,
matching_events: &[Event],
snapshots: &[skilj_core::plugin::SnapshotState],
context: &skilj_core::plugin::DecisionContext,
) -> Option<skilj_core::error::Result<CommandDecision>> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.command_types
.get(&(bounded_context, command_type.to_string()))?;
Some((registered.decide)(
payload,
matching_events,
snapshots,
context,
))
}
fn snapshots(&self) -> Option<&dyn skilj_core::plugin::SnapshotDispatcher> {
Some(&self.snapshots)
}
fn required_role(
&self,
bounded_context: &str,
command_type: &str,
) -> Option<Option<&'static str>> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.command_types
.get(&(bounded_context, command_type.to_string()))?;
Some(registered.required_role)
}
fn snapshot_name(
&self,
bounded_context: &str,
command_type: &str,
) -> Option<Option<&'static str>> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.command_types
.get(&(bounded_context, command_type.to_string()))?;
Some(registered.snapshot_name)
}
fn consistency_query(
&self,
bounded_context: &str,
command_type: &str,
) -> Option<Vec<skilj_core::shared::QueryItemMapping>> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.command_types
.get(&(bounded_context, command_type.to_string()))?;
Some(registered.consistency_query.clone())
}
fn dispatch_from_snapshot(
&self,
bounded_context: &str,
command_type: &str,
payload: &str,
snapshot_state_json: &str,
events_since_snapshot: &[Event],
context: &skilj_core::plugin::DecisionContext,
) -> Option<skilj_core::error::Result<CommandDecision>> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.command_types
.get(&(bounded_context, command_type.to_string()))?;
Some((registered.decide_from_snapshot)(
payload,
snapshot_state_json,
events_since_snapshot,
context,
))
}
}
struct ProjectionDispatcherImpl {
projections: Arc<HashMap<(String, String), RegisteredProjection>>,
template_cache: skilj_core::template_cache::TemplateCache,
}
impl ProjectionDispatcherImpl {
fn registered(
&self,
bounded_context: &str,
projection_name: &str,
) -> Option<&RegisteredProjection> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
self.projections
.get(&(bounded_context, projection_name.to_string()))
}
}
impl skilj_core::plugin::ProjectionDispatcher for ProjectionDispatcherImpl {
fn keys(
&self,
bounded_context: &str,
projection_name: &str,
event: &Event,
) -> Option<Vec<String>> {
Some((self.registered(bounded_context, projection_name)?.keys)(
event,
))
}
fn project(
&self,
bounded_context: &str,
projection_name: &str,
state_json: &str,
event: &Event,
key: &str,
) -> Option<skilj_core::error::Result<String>> {
Some((self.registered(bounded_context, projection_name)?.project)(state_json, event, key))
}
fn default_state(&self, bounded_context: &str, projection_name: &str) -> Option<String> {
Some(
self.registered(bounded_context, projection_name)?
.default_state_json
.clone(),
)
}
fn owner_tag_key(
&self,
bounded_context: &str,
projection_name: &str,
) -> Option<Option<&'static str>> {
Some(
self.registered(bounded_context, projection_name)?
.owner_tag_key,
)
}
fn requires_capability(
&self,
bounded_context: &str,
projection_name: &str,
) -> Option<Option<&'static str>> {
Some(
self.registered(bounded_context, projection_name)?
.requires_capability,
)
}
fn partition_count(&self, bounded_context: &str, projection_name: &str) -> Option<u32> {
Some(
self.registered(bounded_context, projection_name)?
.partition_count,
)
}
}
struct EventDispatcherImpl {
event_types: Arc<HashMap<(String, String), RegisteredEventType>>,
template_cache: skilj_core::template_cache::TemplateCache,
}
impl skilj_core::plugin::EventDispatcher for EventDispatcherImpl {
fn scheduled_payload(&self, bounded_context: &str, event_type: &str) -> Option<String> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.event_types
.get(&(bounded_context, event_type.to_string()))?;
Some((registered.scheduled_payload)())
}
}
struct SnapshotDispatcherImpl {
snapshots: Arc<HashMap<(String, String), RegisteredSnapshot>>,
template_cache: skilj_core::template_cache::TemplateCache,
}
impl skilj_core::plugin::SnapshotDispatcher for SnapshotDispatcherImpl {
fn snapshot_names(&self, bounded_context: &str) -> Vec<&'static str> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
self.snapshots
.iter()
.filter(|((bc, _), _)| *bc == bounded_context)
.map(|(_, registered)| registered.name)
.collect()
}
fn tag_key(&self, bounded_context: &str, snapshot_name: &str) -> Option<&'static str> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.snapshots
.get(&(bounded_context, snapshot_name.to_string()))?;
Some(registered.tag_key)
}
fn owner_tag_key(
&self,
bounded_context: &str,
snapshot_name: &str,
) -> Option<Option<&'static str>> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.snapshots
.get(&(bounded_context, snapshot_name.to_string()))?;
Some(registered.owner_tag_key)
}
fn version(&self, bounded_context: &str, snapshot_name: &str) -> Option<u64> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.snapshots
.get(&(bounded_context, snapshot_name.to_string()))?;
Some(registered.version)
}
fn fold(
&self,
bounded_context: &str,
snapshot_name: &str,
state_json: &str,
event: &Event,
) -> Option<skilj_core::error::Result<String>> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.snapshots
.get(&(bounded_context, snapshot_name.to_string()))?;
Some((registered.fold)(None, state_json, event))
}
fn fold_value(
&self,
bounded_context: &str,
snapshot_name: &str,
tag_value: &str,
state_json: &str,
event: &Event,
) -> Option<skilj_core::error::Result<String>> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.snapshots
.get(&(bounded_context, snapshot_name.to_string()))?;
Some((registered.fold)(Some(tag_value), state_json, event))
}
fn default_state(&self, bounded_context: &str, snapshot_name: &str) -> Option<String> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.snapshots
.get(&(bounded_context, snapshot_name.to_string()))?;
Some(registered.default_state_json.clone())
}
fn partition_count(&self, bounded_context: &str, snapshot_name: &str) -> Option<u32> {
let bounded_context = self
.template_cache
.effective_bounded_context(bounded_context);
let registered = self
.snapshots
.get(&(bounded_context, snapshot_name.to_string()))?;
Some(registered.partition_count)
}
}
struct CrossContextRouteDispatcherImpl {
routes: Arc<HashMap<String, RegisteredCrossContextRoute>>,
}
impl skilj_core::plugin::CrossContextRouteDispatcher for CrossContextRouteDispatcherImpl {
fn routes(&self) -> Vec<skilj_core::plugin::CrossContextRouteInfo> {
self.routes.values().map(|r| r.info).collect()
}
fn route(
&self,
route_name: &str,
source_payload_json: &str,
) -> Option<Result<Option<String>, serde_json::Error>> {
let registered = self.routes.get(route_name)?;
Some((registered.route)(source_payload_json))
}
}
struct ScheduleDeadlineDispatcherImpl {
schedules: Arc<HashMap<String, RegisteredScheduleDeadline>>,
}
impl skilj_core::plugin::ScheduleDeadlineDispatcher for ScheduleDeadlineDispatcherImpl {
fn schedules(&self) -> Vec<skilj_core::plugin::ScheduleDeadlineInfo> {
self.schedules.values().map(|s| s.info).collect()
}
fn schedule(
&self,
schedule_name: &str,
source_payload_json: &str,
) -> Option<Result<Option<skilj_core::plugin::ErasedDeadlineSpec>, serde_json::Error>> {
let registered = self.schedules.get(schedule_name)?;
Some((registered.schedule)(source_payload_json))
}
}
struct CancelDeadlineDispatcherImpl {
cancels: Arc<HashMap<String, RegisteredCancelDeadline>>,
}
impl skilj_core::plugin::CancelDeadlineDispatcher for CancelDeadlineDispatcherImpl {
fn cancels(&self) -> Vec<skilj_core::plugin::CancelDeadlineInfo> {
self.cancels.values().map(|c| c.info).collect()
}
fn cancel_tags(
&self,
cancel_name: &str,
source_payload_json: &str,
) -> Option<Result<Option<Vec<skilj_core::shared::Tag>>, serde_json::Error>> {
let registered = self.cancels.get(cancel_name)?;
Some((registered.cancel_tags)(source_payload_json))
}
}
impl Skilj {
pub async fn shutdown(self, timeout: std::time::Duration) -> ShutdownReport {
let deadline = tokio::time::Instant::now() + timeout;
self.background.stop.send_replace(true);
let tasks = std::mem::take(
&mut *self
.background
.tasks
.lock()
.expect("the background task list is never poisoned"),
);
let mut report = ShutdownReport::default();
for (name, mut handle) in tasks {
if tokio::time::timeout_at(deadline, &mut handle).await.is_ok() {
report.stopped.push(name);
} else {
handle.abort();
let _ = handle.await;
report.aborted.push(name);
}
}
report.pool_closed = tokio::time::timeout_at(deadline, self.pool.close())
.await
.is_ok();
report
}
pub fn builder(database_url: impl Into<String>) -> SkiljBuilder {
SkiljBuilder {
database_url: database_url.into(),
current_bounded_context: skilj_core::plugin::DEFAULT_BOUNDED_CONTEXT.to_string(),
reconciliation_role: None,
identity_provider: None,
event_types: HashMap::new(),
command_types: HashMap::new(),
projections: HashMap::new(),
snapshots: HashMap::new(),
blind_tags: HashMap::new(),
blind_index_key: None,
previous_blind_index_keys: Vec::new(),
cross_context_routes: HashMap::new(),
schedule_deadlines: HashMap::new(),
cancel_deadlines: HashMap::new(),
async_projection_poll_interval: std::time::Duration::from_millis(500),
snapshot_poll_interval: std::time::Duration::from_millis(500),
cross_context_route_poll_interval: std::time::Duration::from_millis(500),
cross_context_route_retry_policy: skilj_retry::RetryPolicy::default(),
deadline_retry_policy: skilj_retry::RetryPolicy::default(),
deadline_poll_interval: std::time::Duration::from_millis(500),
scheduler_poll_interval: std::time::Duration::from_secs(1),
projection_query_wait_timeout: std::time::Duration::from_secs(5),
read_cursor_checkout_lease: std::time::Duration::from_secs(5 * 60),
max_events_per_read: skilj_core::event_store::DEFAULT_MAX_EVENTS_PER_READ,
graphql_limits: skilj_graphql::limits::GraphqlLimits::default(),
graphql_federation: None,
command_batch_idle_in_transaction_timeout: std::time::Duration::from_secs(30),
command_batch_max_size: skilj_core::command_batcher::DEFAULT_MAX_BATCH_SIZE,
max_items_per_batch: skilj_core::command_batcher::DEFAULT_MAX_ITEMS_PER_BATCH,
command_batch_max_concurrent_leaders: None,
key_service: None,
legacy_encryption_master_key: None,
master_key_rotation: MasterKeyRotation::default(),
event_broadcast_capacity: 1024,
event_cache_warm_up_count: 1000,
pool_options: None,
statement_cache_capacity: None,
idempotency_key_retention: Some(DEFAULT_IDEMPOTENCY_KEY_RETENTION),
deadline_retention: Some(DEFAULT_DEADLINE_RETENTION),
slow_command_read_warning: Some(skilj_core::slow_command::DEFAULT_SLOW_READ_WARNING),
slow_command_decide_warning: Some(
skilj_core::slow_command::DEFAULT_SLOW_DECIDE_WARNING,
),
application_version: None,
}
}
pub fn command_dispatcher(&self) -> Arc<dyn CommandDispatcher> {
Arc::new(Dispatcher {
command_types: self.command_types.clone(),
template_cache: self.template_cache.clone(),
snapshots: SnapshotDispatcherImpl {
snapshots: self.snapshots.clone(),
template_cache: self.template_cache.clone(),
},
blind_index: self.blind_index.clone(),
})
}
pub fn projection_dispatcher(&self) -> Arc<dyn skilj_core::plugin::ProjectionDispatcher> {
Arc::new(ProjectionDispatcherImpl {
projections: self.projections.clone(),
template_cache: self.template_cache.clone(),
})
}
pub fn event_dispatcher(&self) -> Arc<dyn skilj_core::plugin::EventDispatcher> {
Arc::new(EventDispatcherImpl {
event_types: self.event_types.clone(),
template_cache: self.template_cache.clone(),
})
}
pub fn snapshot_dispatcher(&self) -> Arc<dyn skilj_core::plugin::SnapshotDispatcher> {
Arc::new(SnapshotDispatcherImpl {
snapshots: self.snapshots.clone(),
template_cache: self.template_cache.clone(),
})
}
pub fn bootstrap_secret(&self) -> Option<String> {
self.bootstrap.current()
}
pub fn rest_router(&self) -> axum::Router {
skilj_rest::router(
self.pool.clone(),
self.command_dispatcher(),
self.projection_dispatcher(),
self.snapshot_dispatcher(),
self.key_service.clone(),
self.event_broadcaster.clone(),
self.event_cache.clone(),
chrono::Duration::from_std(self.read_cursor_checkout_lease)
.unwrap_or(chrono::Duration::zero()),
self.max_events_per_read,
self.command_batcher.clone(),
)
}
pub async fn graphql_router(&self) -> skilj_core::error::Result<axum::Router> {
skilj_graphql::router(Arc::clone(&self.schema_registry), self.graphql_state()).await
}
pub async fn federation_sdl(&self) -> skilj_core::error::Result<Option<String>> {
Ok(self
.schema_registry
.published_sdl(&self.graphql_state())
.await?
.map(|sdl| sdl.to_string()))
}
fn graphql_state(&self) -> skilj_graphql::GraphqlState {
skilj_graphql::GraphqlState {
pool: self.pool.clone(),
parked_delivery_retry_permits: self.parked_delivery_retry_permits.clone(),
bootstrap: self.bootstrap.clone(),
identity: self
.identity_provider
.as_ref()
.map(|ip| skilj_graphql::auth::Identity {
config: ip.config.clone(),
cache: Arc::clone(&ip.cache),
}),
dispatcher: self.command_dispatcher(),
projection_dispatcher: self.projection_dispatcher(),
snapshot_dispatcher: self.snapshot_dispatcher(),
projection_query_wait_timeout: self.projection_query_wait_timeout,
max_events_per_read: self.max_events_per_read,
limits: self.graphql_limits,
key_service: self.key_service.clone(),
event_broadcaster: self.event_broadcaster.clone(),
revocation_broadcaster: self.revocation_broadcaster.clone(),
event_cache: self.event_cache.clone(),
template_cache: self.template_cache.clone(),
command_batcher: self.command_batcher.clone(),
naming: self.graphql_naming.clone(),
federation: self.graphql_federation.clone(),
}
}
}
async fn warn_unpublishable(
pool: &skilj_core::db::Pool,
options: &skilj_graphql::federation::FederationOptions,
) -> Result<(), skilj_core::Error> {
let existing = skilj_core::db::list_bounded_contexts(pool).await?;
for name in options.published() {
match existing.iter().find(|bc| &bc.name == name) {
None => tracing::warn!(
bounded_context = name.as_str(),
"published to the federation supergraph, but no such bounded context exists \
(yet) - left out of the published description until it does"
),
Some(bc) if bc.template.is_some() => tracing::warn!(
bounded_context = name.as_str(),
"published to the federation supergraph, but made from a template - tenants are \
never published (docs/architecture.md §194), so it is left out"
),
Some(_) => {}
}
}
Ok(())
}
#[derive(Debug, Default)]
pub struct ReconciliationReport {
pub registered: Vec<String>,
pub skipped_no_access: Vec<String>,
pub kept_newer: Vec<String>,
pub kept_protections: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MasterKeyRotation {
every: Option<std::time::Duration>,
retire_after: std::time::Duration,
check_interval: std::time::Duration,
}
impl Default for MasterKeyRotation {
fn default() -> Self {
Self {
every: None,
retire_after: std::time::Duration::from_secs(30 * 24 * 60 * 60),
check_interval: std::time::Duration::from_secs(60),
}
}
}
impl MasterKeyRotation {
pub fn new() -> Self {
Self::default()
}
pub fn every(mut self, interval: std::time::Duration) -> Self {
self.every = Some(interval);
self
}
pub fn retire_after(mut self, grace: std::time::Duration) -> Self {
self.retire_after = grace;
self
}
pub fn check_interval(mut self, interval: std::time::Duration) -> Self {
self.check_interval = interval;
self
}
fn validate(&self) -> Result<(), skilj_core::Error> {
if self.check_interval.is_zero() {
return Err(skilj_core::Error::configuration(
"master_key_rotation's check_interval must be more than zero",
));
}
if self.every.is_some_and(|every| every.is_zero()) {
return Err(skilj_core::Error::configuration(
"master_key_rotation's every must be more than zero",
));
}
Ok(())
}
}
async fn check_encryption_keys(
pool: &Pool,
key_service: &KeyService,
) -> Result<(), skilj_core::Error> {
skilj_core::db::register_known_keys(pool, key_service).await?;
let mut settled = BTreeSet::new();
for bounded_context in skilj_core::db::list_bounded_contexts(pool).await? {
match skilj_core::db::check_data_key_wraps(
pool,
&bounded_context.name,
key_service,
&mut settled,
)
.await
{
Ok(wraps) if wraps.legacy => tracing::info!(
bounded_context = %bounded_context.name,
"data keys from before key providers are stored; they are rewrapped in the background"
),
Ok(_) => {}
Err(skilj_core::Error::Database(err))
if err
.as_database_error()
.and_then(|e| e.code())
.is_some_and(|code| code == "42P01" || code == "3F000") => {}
Err(err) => return Err(err),
}
}
if let Err(err) = record_key_in_use(pool, key_service.provider(), None).await {
tracing::error!(
provider = key_service.provider_name(),
error = %err,
"the key provider did not answer at startup; sensitive writes fail until it does"
);
}
Ok(())
}
async fn record_key_in_use(
pool: &Pool,
provider: &dyn KeyProvider,
key: Option<&str>,
) -> Result<(), skilj_core::Error> {
use skilj_core::db::Rotation;
use skilj_core::encryption::trail::RotationReason;
let reason = match skilj_core::db::master_key_state(pool, provider.name()).await? {
Some(_) => RotationReason::External,
None => RotationReason::Adopted,
};
if let Some(version) = skilj_core::db::rotate_master_key(
pool,
provider,
key,
Rotation::Record(reason),
false,
None,
None,
)
.await?
{
tracing::info!(
provider = provider.name(),
version,
reason = reason.as_str(),
"encryption master key version recorded as in use"
);
}
Ok(())
}
async fn master_key_tick(
pool: &Pool,
key_service: &KeyService,
rotation: &MasterKeyRotation,
) -> Result<(), skilj_core::Error> {
skilj_core::db::register_known_keys(pool, key_service).await?;
let existing: BTreeSet<String> = skilj_core::db::list_bounded_contexts(pool)
.await?
.into_iter()
.map(|bc| bc.name)
.collect();
skilj_core::db::forget_assignments_of_deleted_contexts(pool, &existing).await?;
let epochs: BTreeMap<String, i64> = skilj_core::db::master_key_states(pool)
.await?
.into_iter()
.map(|state| (state.provider, state.assignment_epoch))
.collect();
let assignments = skilj_core::db::encryption_key_assignments(pool).await?;
let mut keys: BTreeMap<Option<String>, Vec<String>> = BTreeMap::new();
keys.insert(None, Vec::new());
for bounded_context in &existing {
keys.entry(assignments.get(bounded_context).cloned())
.or_default()
.push(bounded_context.clone());
}
for (key, bounded_contexts) in &keys {
let provider = key_service.provider_for(key.as_deref())?;
if let Err(err) = key_lifecycle(
pool,
key_service,
provider.as_ref(),
key.as_deref(),
bounded_contexts,
epochs.get(provider.name()).copied(),
rotation,
)
.instrument(tracing::info_span!(
"key_lifecycle",
provider = provider.name()
))
.await
{
tracing::warn!(provider = provider.name(), error = %err, "master key lifecycle check failed; retrying");
}
}
let in_use: BTreeSet<String> = keys
.keys()
.map(|key| {
key_service
.provider_for(key.as_deref())
.map(|p| p.name().to_string())
})
.collect::<Result<_, _>>()?;
for state in skilj_core::db::master_key_states(pool).await? {
let Some(key) = state.key.clone() else {
continue;
};
if in_use.contains(&state.provider) {
continue;
}
if let Err(err) = retire_unused_key(pool, key_service, rotation, &state, &key).await {
tracing::warn!(provider = %state.provider, error = %err, "unused key not deleted yet; retrying");
}
}
Ok(())
}
async fn retire_unused_key(
pool: &Pool,
key_service: &KeyService,
rotation: &MasterKeyRotation,
state: &skilj_core::db::MasterKeyState,
key: &str,
) -> Result<(), skilj_core::Error> {
if skilj_core::db::key_in_use(pool, &state.provider).await? {
return Ok(());
}
match state.delete_after {
None => {
let grace =
chrono::Duration::from_std(rotation.retire_after).unwrap_or(chrono::Duration::MAX);
let at = chrono::Utc::now()
.checked_add_signed(grace)
.unwrap_or(chrono::DateTime::<chrono::Utc>::MAX_UTC);
skilj_core::db::schedule_key_deletion(pool, &state.provider, at).await?;
}
Some(_) => {
let provider = key_service.provider_for(Some(key))?;
if skilj_core::db::delete_unused_key(pool, key_service, provider.as_ref()).await? {
tracing::info!(provider = %state.provider, "unused bounded-context key deleted");
}
}
}
Ok(())
}
async fn key_lifecycle(
pool: &Pool,
key_service: &KeyService,
provider: &dyn KeyProvider,
key: Option<&str>,
bounded_contexts: &[String],
epoch: Option<i64>,
rotation: &MasterKeyRotation,
) -> Result<(), skilj_core::Error> {
use skilj_core::db::Rotation;
use skilj_core::encryption::trail::RotationReason;
record_key_in_use(pool, provider, key).await?;
let managed = match skilj_core::db::master_key_state(pool, provider.name()).await? {
Some(state) => state.key.is_none() || state.created_by_skilj,
None => key.is_none(),
};
if let (true, Some(every)) = (managed, rotation.every) {
let every = chrono::Duration::from_std(every).unwrap_or(chrono::Duration::MAX);
if let Some(version) = skilj_core::db::rotate_master_key(
pool,
provider,
key,
Rotation::Rotate(RotationReason::Scheduled),
false,
None,
Some(every),
)
.await?
{
tracing::info!(
provider = provider.name(),
version,
"encryption master key rotated on schedule"
);
}
}
let Some(state) = skilj_core::db::master_key_state(pool, provider.name()).await? else {
return Ok(());
};
let behind = |bounded_context: &str| {
let bounded_context = bounded_context.to_string();
async move {
match skilj_core::db::data_keys_behind(
pool,
&bounded_context,
provider.name(),
state.version,
state.rotated_at,
)
.await
{
Ok(behind) => Ok(behind),
Err(skilj_core::Error::Database(err))
if err
.as_database_error()
.and_then(|e| e.code())
.is_some_and(|code| code == "42P01" || code == "3F000") =>
{
Ok(false)
}
Err(err) => Err(err),
}
}
};
let Some(rewrapped_at) = state.rewrapped_at else {
let mut still_behind = false;
for bounded_context in bounded_contexts {
if !behind(bounded_context).await? {
continue;
}
let started = std::time::Instant::now();
let Some(outcome) = skilj_core::db::rewrap_data_keys_once(
pool,
bounded_context,
key_service,
provider,
state.version,
state.rotated_at,
)
.await?
else {
still_behind = true;
continue;
};
tracing::info!(
bounded_context = %bounded_context,
provider = provider.name(),
version = state.version,
rewrapped = outcome.rewrapped,
unreadable = outcome.unreadable,
elapsed_ms = started.elapsed().as_millis() as u64,
"data keys rewrapped under the bounded context's key"
);
still_behind |= behind(bounded_context).await?;
}
let Some(epoch) = epoch else {
return Ok(());
};
if !still_behind
&& skilj_core::db::mark_data_keys_rewrapped(pool, provider.name(), state.version, epoch)
.await?
{
tracing::info!(
provider = provider.name(),
version = state.version,
"every data key is wrapped under the key's current version"
);
}
return Ok(());
};
if !managed || state.retired_below >= state.version {
return Ok(());
}
let grace = if state.retire_immediately {
chrono::Duration::zero()
} else {
chrono::Duration::from_std(rotation.retire_after).unwrap_or(chrono::Duration::MAX)
};
if rewrapped_at
.checked_add_signed(grace)
.is_some_and(|due| due > chrono::Utc::now())
|| rewrapped_at.checked_add_signed(grace).is_none()
{
return Ok(());
}
for bounded_context in bounded_contexts {
if behind(bounded_context).await? {
skilj_core::db::mark_data_keys_behind(pool, provider.name()).await?;
return Ok(());
}
}
if skilj_core::db::retire_master_key_versions(pool, key_service, provider, state.version)
.await?
{
tracing::info!(
provider = provider.name(),
below = state.version,
"encryption master key versions retired at the provider"
);
}
Ok(())
}
fn warn_on_small_statement_cache(
capacity: usize,
bounded_contexts: &[skilj_core::event_store::BoundedContext],
) -> bool {
let active = bounded_contexts
.iter()
.filter(|bc| bc.status == skilj_core::event_store::BoundedContextStatus::Active)
.count();
let wanted = active * skilj_core::db::STATEMENT_CACHE_MIN_PER_BOUNDED_CONTEXT;
if capacity >= wanted {
return false;
}
tracing::warn!(
statement_cache_capacity = capacity,
active_bounded_contexts = active,
"the statement cache holds {capacity} statements per connection, fewer than \
{wanted} for {active} active bounded contexts: hot statements will be evicted \
and prepared again; set SkiljBuilder::statement_cache_capacity, or the \
database URL's statement-cache-capacity (see docs/performance.md)"
);
true
}
fn keep_stored_protections(
ours_sensitive: &[skilj_core::shared::SensitiveField],
ours_private: &[skilj_core::shared::PrivateField],
ours_owner_tag_key: &Option<String>,
stored_sensitive: &[skilj_core::shared::SensitiveField],
stored_private: &[skilj_core::shared::PrivateField],
stored_owner_tag_key: &Option<String>,
) -> (
Vec<skilj_core::shared::SensitiveField>,
Vec<skilj_core::shared::PrivateField>,
Option<String>,
bool,
) {
let mut sensitive = ours_sensitive.to_vec();
for kept in stored_sensitive {
if !sensitive.iter().any(|s| s.field == kept.field) {
sensitive.push(kept.clone());
}
}
let mut private = ours_private.to_vec();
for kept in stored_private {
if !private.iter().any(|p| p.field == kept.field) {
private.push(kept.clone());
}
}
let owner_tag_key = ours_owner_tag_key
.clone()
.or_else(|| stored_owner_tag_key.clone());
let added = sensitive.len() != ours_sensitive.len()
|| private.len() != ours_private.len()
|| owner_tag_key != *ours_owner_tag_key;
(sensitive, private, owner_tag_key, added)
}
struct RegistrationVersion {
version: Option<i64>,
ensured: std::sync::Mutex<std::collections::HashSet<String>>,
}
impl RegistrationVersion {
fn new(version: Option<u64>) -> Self {
Self {
version: version.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
ensured: Default::default(),
}
}
async fn is_older_than_stored(
&self,
pool: &Pool,
bounded_context: &str,
table: skilj_core::db::RegistrationTable,
name: &str,
) -> skilj_core::error::Result<bool> {
let Some(ours) = self.version else {
return Ok(false);
};
self.ensure_columns(pool, bounded_context).await?;
Ok(
skilj_core::db::registration_version(pool, bounded_context, table, name)
.await?
.is_some_and(|stored| ours < stored),
)
}
async fn ensure_columns(
&self,
pool: &Pool,
bounded_context: &str,
) -> skilj_core::error::Result<()> {
let first = self
.ensured
.lock()
.expect("never poisoned: nothing panics while holding it")
.insert(bounded_context.to_string());
if first {
skilj_core::db::ensure_registration_version_columns(pool, bounded_context).await?;
}
Ok(())
}
async fn stamp(
&self,
pool: &Pool,
bounded_context: &str,
table: skilj_core::db::RegistrationTable,
name: &str,
) -> skilj_core::error::Result<()> {
match self.version {
Some(version) => {
self.ensure_columns(pool, bounded_context).await?;
skilj_core::db::set_registration_version(
pool,
bounded_context,
table,
name,
version,
)
.await
}
None => Ok(()),
}
}
}
fn is_older_than_stored_rejection(error: &skilj_core::Error) -> bool {
use skilj_core::error::SkiljRejection;
matches!(
error.code(),
"schema_incompatible" | "tag_mapping_key_dropped"
)
}
type ScheduledPayloadFn = Box<dyn Fn() -> String + Send + Sync>;
struct RegisteredEventType {
schema: String,
tag_mappings: Vec<skilj_core::shared::TagMapping>,
owner_tag_key: Option<String>,
sensitive_fields: Vec<skilj_core::shared::SensitiveField>,
private_fields: Vec<skilj_core::shared::PrivateField>,
external_creation_allowed: bool,
direct_creation_allowed: bool,
event_read_allowed: bool,
system_triggered_allowed: bool,
system_triggered_schedule: Option<String>,
missed_occurrence_policy: Option<skilj_core::event_store::MissedOccurrencePolicy>,
scheduled_payload: ScheduledPayloadFn,
}
fn registered_event_type<T: EventType + 'static>() -> RegisteredEventType {
let schema = schemars::schema_for!(T::Payload);
RegisteredEventType {
schema: serde_json::to_string(&schema).expect("JSON Schema serialization is infallible"),
tag_mappings: T::tag_mappings(),
owner_tag_key: T::owner_tag_key().map(str::to_string),
sensitive_fields: T::sensitive_fields(),
private_fields: T::private_fields(),
external_creation_allowed: T::external_creation_allowed(),
direct_creation_allowed: T::direct_creation_allowed(),
event_read_allowed: T::event_read_allowed(),
system_triggered_allowed: T::system_triggered_allowed(),
system_triggered_schedule: T::system_triggered_schedule(),
missed_occurrence_policy: T::missed_occurrence_policy(),
scheduled_payload: Box::new(|| {
serde_json::to_string(&T::scheduled_payload())
.expect("JSON serialization of Payload is infallible")
}),
}
}
type DeciderFn = Box<
dyn Fn(
&str,
&[Event],
&[skilj_core::plugin::SnapshotState],
&skilj_core::plugin::DecisionContext,
) -> skilj_core::error::Result<CommandDecision>
+ Send
+ Sync,
>;
type DecideFromSnapshotFn = Box<
dyn Fn(
&str,
&str,
&[Event],
&skilj_core::plugin::DecisionContext,
) -> skilj_core::error::Result<CommandDecision>
+ Send
+ Sync,
>;
struct RegisteredCommandType {
schema: String,
tag_mappings: Vec<skilj_core::shared::TagMapping>,
owner_tag_key: Option<String>,
sensitive_fields: Vec<skilj_core::shared::SensitiveField>,
private_fields: Vec<skilj_core::shared::PrivateField>,
rest_trigger_allowed: bool,
approval_policy: Option<skilj_core::approvals::CommandApprovalPolicy>,
required_role: Option<&'static str>,
snapshot_name: Option<&'static str>,
consistency_query: Vec<skilj_core::shared::QueryItemMapping>,
decide: DeciderFn,
decide_from_snapshot: DecideFromSnapshotFn,
}
fn validate_blind_tags(
blind_tags: &HashMap<(String, String), RegisteredBlindTag>,
has_key: bool,
event_types: &HashMap<(String, String), RegisteredEventType>,
command_types: &HashMap<(String, String), RegisteredCommandType>,
snapshots: &HashMap<(String, String), RegisteredSnapshot>,
) -> Result<(), skilj_core::Error> {
if !blind_tags.is_empty() && !has_key {
return Err(skilj_core::Error::configuration(
"a BlindTag is declared, but no blind_index_key is configured \
(SkiljBuilder::blind_index_key)",
));
}
let types = event_types
.iter()
.map(|((bc, name), t)| (bc, name, &t.tag_mappings))
.chain(
command_types
.iter()
.map(|((bc, name), t)| (bc, name, &t.tag_mappings)),
);
let mut types: Vec<_> = types.collect();
types.sort_by_key(|(bc, name, _)| (bc.as_str(), name.as_str()));
for (bc, name, tag_mappings) in types {
let refuse = |why: String| {
Err(skilj_core::Error::configuration(format!(
"type {name} in bounded context {bc}: {why}"
)))
};
for mapping in tag_mappings {
let declared = blind_tags.get(&(bc.clone(), mapping.key.clone()));
match (mapping.blind, declared) {
(true, None) => {
return refuse(format!(
"tag key {} is mapped blind, but no BlindTag declares it",
mapping.key
))
}
(false, Some(_)) => {
return refuse(format!(
"tag key {} is a BlindTag; map it with TagMapping::blind",
mapping.key
))
}
(true, Some(declared)) => {
if let skilj_core::blind::Erasure::WithSubject { subject_tag, .. } =
&declared.erasure
{
if !tag_mappings
.iter()
.any(|m| &m.key == subject_tag && !m.blind)
{
return refuse(format!(
"tag key {} is erased with its subject, so the type must also \
map its subject_tag {subject_tag} plainly",
mapping.key
));
}
}
}
(false, None) => {}
}
}
}
let mut declared: Vec<_> = blind_tags.iter().collect();
declared.sort_by_key(|(key, _)| *key);
for ((bc, tag_key), blind_tag) in declared {
let skilj_core::blind::Erasure::WithSubject { subject_tag, .. } = &blind_tag.erasure else {
continue;
};
if blind_tags.contains_key(&(bc.clone(), subject_tag.clone())) {
return Err(skilj_core::Error::configuration(format!(
"blind tag {tag_key} in bounded context {bc}: its subject_tag {subject_tag} is \
itself blind"
)));
}
if let Some(((_, snapshot), _)) = snapshots
.iter()
.find(|((sbc, _), s)| sbc == bc && s.tag_key == tag_key)
{
return Err(skilj_core::Error::configuration(format!(
"snapshot {snapshot} in bounded context {bc} is keyed by blind tag {tag_key}, \
which is erased with its subject: the snapshot's rows would keep the hash"
)));
}
}
Ok(())
}
struct PendingBlindRotation {
bounded_context: String,
declaring: String,
tag_key: String,
generation: u32,
}
async fn check_stored_blind_tags(
pool: &Pool,
blind_tags: &HashMap<(String, String), RegisteredBlindTag>,
blind_index: &skilj_core::blind::BlindIndex,
template_cache: &skilj_core::template_cache::TemplateCache,
) -> Result<Vec<PendingBlindRotation>, skilj_core::Error> {
let mut pending = Vec::new();
if blind_tags.is_empty() {
return Ok(pending);
}
let declaring: HashSet<&String> = blind_tags.keys().map(|(bc, _)| bc).collect();
let mut missing = Vec::new();
for bounded_context in skilj_core::db::list_bounded_contexts(pool).await? {
let bc = bounded_context.name;
let effective = template_cache.effective_bounded_context(&bc);
if !declaring.contains(&effective) {
continue;
}
let stored_tags = match skilj_core::db::list_blind_tags(pool, &bc).await {
Ok(stored_tags) => stored_tags,
Err(skilj_core::Error::Database(err))
if err
.as_database_error()
.and_then(|e| e.code())
.is_some_and(|code| code == "42P01" || code == "3F000") =>
{
continue
}
Err(err) => return Err(err),
};
for stored in stored_tags {
let Some(declared) = blind_tags.get(&(effective.clone(), stored.tag_key.clone()))
else {
continue;
};
if declared.normalizer_version != stored.normalizer_version {
return Err(skilj_core::event_store::Error::BlindNormalizerChanged {
tag_key: stored.tag_key,
stored: i64::from(stored.normalizer_version),
declared: declared.normalizer_version,
}
.into());
}
let generation = stored.key_generation;
if generation > blind_index.current_generation() {
return Err(skilj_core::blind::Error::KeyNotConfigured {
bounded_context: bc,
tag_key: stored.tag_key,
generation,
}
.into());
}
blind_index.observe_generation(&bc, &stored.tag_key, generation);
let Some(fingerprint) = blind_index.fingerprint(&bc, &stored.tag_key, generation)
else {
continue;
};
match stored.key_fingerprint {
Some(stored_fingerprint) if stored_fingerprint != fingerprint => {
return Err(skilj_core::event_store::Error::BlindIndexKeyChanged {
bounded_context: bc,
tag_key: stored.tag_key,
}
.into());
}
Some(_) => {}
None => missing.push((bc.clone(), stored.tag_key.clone(), fingerprint)),
}
if generation < blind_index.current_generation() {
pending.push(PendingBlindRotation {
bounded_context: bc.clone(),
declaring: effective.clone(),
tag_key: stored.tag_key,
generation,
});
}
}
}
for (bc, tag_key, fingerprint) in missing {
skilj_core::db::record_blind_tag_fingerprint(pool, &bc, &tag_key, &fingerprint).await?;
}
Ok(pending)
}
async fn rotate_blind_tags(
pool: &Pool,
role: &Role,
pending: Vec<PendingBlindRotation>,
blind_index: &skilj_core::blind::BlindIndex,
report: &mut ReconciliationReport,
) -> Result<(), skilj_core::Error> {
for rotation in pending {
let PendingBlindRotation {
bounded_context: bc,
declaring,
tag_key,
generation,
} = rotation;
let administers = resolve_bounded_context_and_admin_mapping(pool, &role.id, &bc)
.await?
.is_some()
|| resolve_bounded_context_and_admin_mapping(pool, &role.id, &declaring)
.await?
.is_some();
if !administers {
report
.skipped_no_access
.push(format!("{bc}/{tag_key} (blind-index key rotation)"));
continue;
}
for from in generation..blind_index.current_generation() {
let to = from + 1;
let fingerprint = blind_index
.fingerprint(&bc, &tag_key, to)
.expect("checked at startup: this process declares the constraint and has the key");
let wrap = |tag: &str| -> skilj_core::error::Result<String> {
Ok(blind_index.wrap(&bc, &tag_key, to, tag)?)
};
let started = std::time::Instant::now();
if !skilj_core::db::rotate_blind_tag(pool, &bc, &tag_key, from, &wrap, &fingerprint)
.await?
{
tracing::info!(
bounded_context = %bc,
tag_key = %tag_key,
"blind tag rotated by another instance meanwhile"
);
break;
}
blind_index.observe_generation(&bc, &tag_key, to);
tracing::info!(
bounded_context = %bc,
tag_key = %tag_key,
generation = to,
elapsed_ms = started.elapsed().as_millis() as u64,
"blind tag rotated to the current blind-index key"
);
}
}
Ok(())
}
async fn reconcile_blind_tags(
pool: &Pool,
role: &Role,
blind_tags: &HashMap<(String, String), RegisteredBlindTag>,
blind_index: &skilj_core::blind::BlindIndex,
report: &mut ReconciliationReport,
) -> Result<(), skilj_core::Error> {
let mut declared: Vec<_> = blind_tags.iter().collect();
declared.sort_by_key(|(key, _)| *key);
for ((bounded_context_name, tag_key), registered) in declared {
if resolve_bounded_context_and_admin_mapping(pool, &role.id, bounded_context_name)
.await?
.is_none()
{
report
.skipped_no_access
.push(format!("{bounded_context_name}/{tag_key}"));
continue;
}
skilj_core::db::register_blind_tag(
pool,
bounded_context_name,
&skilj_core::blind::BlindTagDeclaration {
tag_key: tag_key.clone(),
normalizer_version: registered.normalizer_version,
erasure: registered.erasure.clone(),
key_fingerprint: blind_index.fingerprint(
bounded_context_name,
tag_key,
blind_index.current_generation(),
),
key_generation: blind_index.current_generation(),
},
chrono::Utc::now(),
)
.await?;
}
Ok(())
}
fn validate_consistency_queries(
command_types: &HashMap<(String, String), RegisteredCommandType>,
event_types: &HashMap<(String, String), RegisteredEventType>,
snapshots: &HashMap<(String, String), RegisteredSnapshot>,
) -> Result<(), skilj_core::Error> {
let mut names: Vec<&(String, String)> = command_types.keys().collect();
names.sort();
for key in names {
let (bounded_context, name) = key;
let registered = &command_types[key];
if registered.consistency_query.is_empty() {
continue;
}
let refuse = |why: String| {
Err(skilj_core::Error::configuration(format!(
"command type {name} in bounded context {bounded_context}: {why}"
)))
};
if registered.snapshot_name.is_some() {
return refuse(
"declares both consistency_query() and snapshot(); a snapshot folds by \
tag and can't stand in for a query"
.to_string(),
);
}
for item in ®istered.consistency_query {
if let Some(snapshot) = &item.snapshot {
let Some(registered_snapshot) =
snapshots.get(&(bounded_context.clone(), snapshot.clone()))
else {
return refuse(format!(
"consistency_query() names snapshot {snapshot}, which isn't registered \
in this bounded context"
));
};
let [mapping] = item.tag_mappings.as_slice() else {
return refuse(format!(
"a consistency_query() item for snapshot {snapshot} needs exactly one \
tag mapping, the value whose state it reads"
));
};
if mapping.key != registered_snapshot.tag_key {
return refuse(format!(
"a consistency_query() item for snapshot {snapshot} maps tag {}, but \
the snapshot is keyed by {}",
mapping.key, registered_snapshot.tag_key
));
}
if !item.event_types.is_empty() || item.latest.is_some() {
return refuse(format!(
"a consistency_query() item for snapshot {snapshot} folds every event \
carrying its tag, so it takes neither event types nor latest"
));
}
if !registered.tag_mappings.contains(mapping) {
return refuse(format!(
"consistency_query() tag {} (field {}) isn't one of its tag_mappings()",
mapping.key, mapping.field
));
}
continue;
}
if item.event_types.is_empty() && item.tag_mappings.is_empty() {
if registered.consistency_query.len() > 1 {
return refuse(
"a consistency_query() item names neither event types nor tags, so it \
matches nothing; QueryItemMapping::nothing() stands alone"
.to_string(),
);
}
if item.latest.is_some() {
return refuse(
"QueryItemMapping::nothing() reads no events, so it can't keep the \
latest of them"
.to_string(),
);
}
continue;
}
if item.latest == Some(0) {
return refuse(
"a consistency_query() item keeps the last 0 matching events; \
leave latest unset for every match"
.to_string(),
);
}
for event_type in &item.event_types {
if !event_types.contains_key(&(bounded_context.clone(), event_type.clone())) {
return refuse(format!(
"consistency_query() names event type {event_type}, which isn't \
registered in this bounded context"
));
}
}
for mapping in &item.tag_mappings {
if !registered.tag_mappings.contains(mapping) {
return refuse(format!(
"consistency_query() tag {} (field {}) isn't one of its tag_mappings()",
mapping.key, mapping.field
));
}
}
}
}
Ok(())
}
fn registered_command_type<T: CommandType + 'static>() -> RegisteredCommandType {
let schema = schemars::schema_for!(T::Payload);
RegisteredCommandType {
schema: serde_json::to_string(&schema).expect("JSON Schema serialization is infallible"),
tag_mappings: T::tag_mappings(),
owner_tag_key: T::owner_tag_key().map(str::to_string),
sensitive_fields: T::sensitive_fields(),
private_fields: T::private_fields(),
rest_trigger_allowed: T::rest_trigger_allowed(),
approval_policy: T::requires_approval().then(|| {
skilj_core::approvals::CommandApprovalPolicy::new(
T::approval_role().map(str::to_string),
T::approval_deadline_seconds(),
)
.allowing_self_approval(T::approval_allows_self_approval())
}),
required_role: T::required_role(),
snapshot_name: T::snapshot(),
consistency_query: T::consistency_query(),
decide: Box::new(|payload_json, raw_events, snapshots, context| {
let payload: T::Payload = serde_json::from_str(payload_json)
.map_err(|e| EventStoreError::PayloadDecodeFailed(e.to_string()))?;
let mut matching = Vec::with_capacity(raw_events.len());
for event in raw_events {
if let Some(converted) = T::Event::try_from_event(event) {
matching.push(
converted
.map_err(|e| EventStoreError::PayloadDecodeFailed(e.to_string()))?,
);
}
}
Ok(T::decide_with(
&payload,
&skilj_core::plugin::Decision::new(&matching, snapshots, context),
))
}),
decide_from_snapshot: Box::new(|payload_json, snapshot_state_json, raw_events, context| {
let payload: T::Payload = serde_json::from_str(payload_json)
.map_err(|e| EventStoreError::PayloadDecodeFailed(e.to_string()))?;
let mut matching = Vec::with_capacity(raw_events.len());
for event in raw_events {
if let Some(converted) = T::Event::try_from_event(event) {
matching.push(
converted
.map_err(|e| EventStoreError::PayloadDecodeFailed(e.to_string()))?,
);
}
}
Ok(T::decide_from_snapshot(
&payload,
snapshot_state_json,
&matching,
context,
))
}),
}
}
type KeysFn = Box<dyn Fn(&Event) -> Vec<String> + Send + Sync>;
type ProjectFn = Box<dyn Fn(&str, &Event, &str) -> skilj_core::error::Result<String> + Send + Sync>;
struct RegisteredProjection {
schema: String,
consumed_event_types: Vec<&'static str>,
sync: bool,
default_state_json: String,
owner_tag_key: Option<&'static str>,
requires_capability: Option<&'static str>,
partition_count: u32,
keys: KeysFn,
project: ProjectFn,
}
fn registered_projection<T: Projection + 'static>() -> RegisteredProjection {
let schema = schemars::schema_for!(T::State);
let consumed_event_types = T::consumed_event_types();
let consumed_for_keys = consumed_event_types.clone();
let consumed_for_project = consumed_event_types.clone();
let default_state_json = serde_json::to_string(&T::State::default())
.expect("JSON serialization of a Default::default() State is infallible");
RegisteredProjection {
schema: serde_json::to_string(&schema).expect("JSON Schema serialization is infallible"),
consumed_event_types,
sync: T::sync(),
default_state_json,
owner_tag_key: T::OWNER_TAG_KEY,
requires_capability: T::REQUIRES_CAPABILITY,
partition_count: T::PARTITION_COUNT,
keys: Box::new(move |event| {
if !consumed_for_keys
.iter()
.any(|&name| name == event.event_type.name)
{
return Vec::new();
}
let Some(Ok(converted)) = T::Event::try_from_event(event) else {
return Vec::new();
};
T::keys(&converted)
}),
project: Box::new(move |state_json, event, key| {
if !consumed_for_project
.iter()
.any(|&name| name == event.event_type.name)
{
return Ok(state_json.to_string());
}
let mut state: T::State = serde_json::from_str(state_json)
.map_err(|e| EventStoreError::PayloadDecodeFailed(e.to_string()))?;
let Some(converted) = T::Event::try_from_event(event) else {
return Ok(state_json.to_string());
};
let converted =
converted.map_err(|e| EventStoreError::PayloadDecodeFailed(e.to_string()))?;
T::project(&mut state, &converted, key);
Ok(serde_json::to_string(&state).expect("JSON serialization of State is infallible"))
}),
}
}
type SnapshotFoldFn =
Box<dyn Fn(Option<&str>, &str, &Event) -> skilj_core::error::Result<String> + Send + Sync>;
struct RegisteredSnapshot {
name: &'static str,
tag_key: &'static str,
owner_tag_key: Option<&'static str>,
version: u64,
partition_count: u32,
default_state_json: String,
fold: SnapshotFoldFn,
}
fn registered_snapshot<T: Snapshot + 'static>() -> RegisteredSnapshot {
let default_state_json = serde_json::to_string(&T::State::default())
.expect("JSON serialization of a Default::default() State is infallible");
RegisteredSnapshot {
name: T::NAME,
tag_key: T::TAG_KEY,
owner_tag_key: T::OWNER_TAG_KEY,
version: T::VERSION,
partition_count: T::PARTITION_COUNT,
default_state_json,
fold: Box::new(|tag_value, state_json, event| {
let mut state: T::State = serde_json::from_str(state_json)
.map_err(|e| EventStoreError::PayloadDecodeFailed(e.to_string()))?;
let converted = T::Event::try_from_event(event).ok_or_else(|| {
EventStoreError::PayloadDecodeFailed(format!(
"event type {} does not convert via this bounded context's own \
BoundedContextEvent - snapshot {} can't fold it",
event.event_type.name,
T::NAME
))
})?;
let converted =
converted.map_err(|e| EventStoreError::PayloadDecodeFailed(e.to_string()))?;
match tag_value {
Some(value) => T::fold_for(&mut state, value, &converted),
None => T::fold(&mut state, &converted),
}
Ok(serde_json::to_string(&state).expect("JSON serialization of State is infallible"))
}),
}
}
struct RegisteredCrossContextRoute {
info: skilj_core::plugin::CrossContextRouteInfo,
route: fn(&str) -> Result<Option<String>, serde_json::Error>,
}
fn registered_cross_context_route<R: CrossContextRoute + 'static>() -> RegisteredCrossContextRoute {
RegisteredCrossContextRoute {
info: skilj_core::plugin::CrossContextRouteInfo {
name: R::NAME,
source_bounded_context: R::Source::BOUNDED_CONTEXT,
source_event_type: R::Source::NAME,
target_bounded_context: R::Target::BOUNDED_CONTEXT,
target_command_type: R::Target::NAME,
start_from: R::START_FROM,
},
route: |payload_json| {
let source_payload: <R::Source as EventType>::Payload =
serde_json::from_str(payload_json)?;
match R::route(&source_payload) {
None => Ok(None),
Some(target_payload) => Ok(Some(serde_json::to_string(&target_payload)?)),
}
},
}
}
struct RegisteredScheduleDeadline {
info: skilj_core::plugin::ScheduleDeadlineInfo,
schedule: fn(&str) -> Result<Option<skilj_core::plugin::ErasedDeadlineSpec>, serde_json::Error>,
}
fn registered_schedule_deadline<S: ScheduleDeadline + 'static>() -> RegisteredScheduleDeadline {
RegisteredScheduleDeadline {
info: skilj_core::plugin::ScheduleDeadlineInfo {
name: S::NAME,
source_bounded_context: S::Source::BOUNDED_CONTEXT,
source_event_type: S::Source::NAME,
target_bounded_context: S::Target::BOUNDED_CONTEXT,
target_command_type: S::Target::NAME,
start_from: S::START_FROM,
},
schedule: |payload_json| {
let source_payload: <S::Source as EventType>::Payload =
serde_json::from_str(payload_json)?;
match S::schedule(&source_payload) {
None => Ok(None),
Some(spec) => Ok(Some(skilj_core::plugin::ErasedDeadlineSpec {
fire_at: spec.fire_at,
expires_at: spec.expires_at,
tags: spec.tags,
payload_json: serde_json::to_string(&spec.payload)?,
recurrence: spec.recurrence,
})),
}
},
}
}
struct RegisteredCancelDeadline {
info: skilj_core::plugin::CancelDeadlineInfo,
cancel_tags: fn(&str) -> Result<Option<Vec<skilj_core::shared::Tag>>, serde_json::Error>,
}
fn registered_cancel_deadline<C: CancelDeadline + 'static>() -> RegisteredCancelDeadline {
RegisteredCancelDeadline {
info: skilj_core::plugin::CancelDeadlineInfo {
name: C::NAME,
source_bounded_context: C::Source::BOUNDED_CONTEXT,
source_event_type: C::Source::NAME,
deadline_schedule_name: C::Deadline::NAME,
deadline_schedule_bounded_context:
<<C::Deadline as ScheduleDeadline>::Source as EventType>::BOUNDED_CONTEXT,
deadline_schedule_source_event_type:
<<C::Deadline as ScheduleDeadline>::Source as EventType>::NAME,
start_from: C::START_FROM,
},
cancel_tags: |payload_json| {
let source_payload: <C::Source as EventType>::Payload =
serde_json::from_str(payload_json)?;
Ok(C::cancel_tags(&source_payload))
},
}
}
pub struct EventTypeRegistrar(pub fn(SkiljBuilder) -> SkiljBuilder);
inventory::collect!(EventTypeRegistrar);
pub struct CommandTypeRegistrar(pub fn(SkiljBuilder) -> SkiljBuilder);
inventory::collect!(CommandTypeRegistrar);
pub struct ProjectionRegistrar(pub fn(SkiljBuilder) -> SkiljBuilder);
inventory::collect!(ProjectionRegistrar);
pub struct SnapshotRegistrar(pub fn(SkiljBuilder) -> SkiljBuilder);
inventory::collect!(SnapshotRegistrar);
pub struct BlindTagRegistrar(pub fn(SkiljBuilder) -> SkiljBuilder);
inventory::collect!(BlindTagRegistrar);
struct RegisteredBlindTag {
normalizer_version: u32,
erasure: skilj_core::blind::Erasure,
normalize: skilj_core::blind::Normalizer,
}
pub struct SkiljBuilder {
database_url: String,
current_bounded_context: String,
reconciliation_role: Option<String>,
identity_provider: Option<IdpConfig>,
event_types: HashMap<(String, String), RegisteredEventType>,
command_types: HashMap<(String, String), RegisteredCommandType>,
projections: HashMap<(String, String), RegisteredProjection>,
snapshots: HashMap<(String, String), RegisteredSnapshot>,
blind_tags: HashMap<(String, String), RegisteredBlindTag>,
blind_index_key: Option<skilj_core::blind::BlindIndexKey>,
previous_blind_index_keys: Vec<skilj_core::blind::BlindIndexKey>,
cross_context_routes: HashMap<String, RegisteredCrossContextRoute>,
schedule_deadlines: HashMap<String, RegisteredScheduleDeadline>,
cancel_deadlines: HashMap<String, RegisteredCancelDeadline>,
async_projection_poll_interval: std::time::Duration,
snapshot_poll_interval: std::time::Duration,
cross_context_route_poll_interval: std::time::Duration,
cross_context_route_retry_policy: skilj_retry::RetryPolicy,
deadline_retry_policy: skilj_retry::RetryPolicy,
deadline_poll_interval: std::time::Duration,
scheduler_poll_interval: std::time::Duration,
projection_query_wait_timeout: std::time::Duration,
read_cursor_checkout_lease: std::time::Duration,
max_events_per_read: usize,
graphql_limits: skilj_graphql::limits::GraphqlLimits,
graphql_federation: Option<skilj_graphql::federation::FederationOptions>,
command_batch_idle_in_transaction_timeout: std::time::Duration,
command_batch_max_size: usize,
max_items_per_batch: usize,
command_batch_max_concurrent_leaders: Option<usize>,
key_service: Option<KeyService>,
legacy_encryption_master_key: Option<EncryptionMasterKey>,
master_key_rotation: MasterKeyRotation,
event_broadcast_capacity: usize,
event_cache_warm_up_count: usize,
pool_options: Option<PgPoolOptions>,
statement_cache_capacity: Option<usize>,
idempotency_key_retention: Option<std::time::Duration>,
deadline_retention: Option<std::time::Duration>,
slow_command_read_warning: Option<std::time::Duration>,
slow_command_decide_warning: Option<std::time::Duration>,
application_version: Option<u64>,
}
impl SkiljBuilder {
pub fn bounded_context(mut self, name: impl Into<String>) -> Self {
self.current_bounded_context = name.into();
self
}
fn current_bounded_context(&self) -> String {
self.current_bounded_context.clone()
}
pub fn auto_register(mut self) -> Self {
for registrar in inventory::iter::<EventTypeRegistrar> {
self = (registrar.0)(self);
}
for registrar in inventory::iter::<CommandTypeRegistrar> {
self = (registrar.0)(self);
}
for registrar in inventory::iter::<ProjectionRegistrar> {
self = (registrar.0)(self);
}
for registrar in inventory::iter::<SnapshotRegistrar> {
self = (registrar.0)(self);
}
for registrar in inventory::iter::<BlindTagRegistrar> {
self = (registrar.0)(self);
}
self
}
pub fn event_type<T: EventType + 'static>(mut self) -> Self {
let bc = self.current_bounded_context();
self.event_types
.insert((bc, T::NAME.to_string()), registered_event_type::<T>());
self
}
pub fn command_type<T: CommandType + 'static>(mut self) -> Self {
let bc = self.current_bounded_context();
self.command_types
.insert((bc, T::NAME.to_string()), registered_command_type::<T>());
self
}
pub fn projection<T: Projection + 'static>(mut self) -> Self {
let bc = self.current_bounded_context();
self.projections
.insert((bc, T::NAME.to_string()), registered_projection::<T>());
self
}
pub fn snapshot<T: Snapshot + 'static>(mut self) -> Self {
let bc = self.current_bounded_context();
self.snapshots
.insert((bc, T::NAME.to_string()), registered_snapshot::<T>());
self
}
pub fn blind_tag<B: BlindTag + 'static>(mut self) -> Self {
let bc = self.current_bounded_context();
self.blind_tags.insert(
(bc, B::TAG_KEY.to_string()),
RegisteredBlindTag {
normalizer_version: B::NORMALIZER_VERSION,
erasure: B::ERASURE.into(),
normalize: B::normalize,
},
);
self
}
pub fn blind_index_key(mut self, key: BlindIndexKey) -> Self {
self.blind_index_key = Some(key);
self
}
pub fn previous_blind_index_keys(
mut self,
keys: impl IntoIterator<Item = BlindIndexKey>,
) -> Self {
self.previous_blind_index_keys = keys.into_iter().collect();
self
}
pub fn cross_context_route<R: CrossContextRoute + 'static>(mut self) -> Self {
self.cross_context_routes
.insert(R::NAME.to_string(), registered_cross_context_route::<R>());
self
}
pub fn schedule_deadline<S: ScheduleDeadline + 'static>(mut self) -> Self {
self.schedule_deadlines
.insert(S::NAME.to_string(), registered_schedule_deadline::<S>());
self
}
pub fn cancel_deadline<C: CancelDeadline + 'static>(mut self) -> Self {
self.cancel_deadlines
.insert(C::NAME.to_string(), registered_cancel_deadline::<C>());
self
}
pub fn reconciliation_role(mut self, external_subject: impl Into<String>) -> Self {
self.reconciliation_role = Some(external_subject.into());
self
}
pub fn identity_provider(mut self, config: IdpConfig) -> Self {
self.identity_provider = Some(config);
self
}
pub fn async_projection_poll_interval(mut self, interval: std::time::Duration) -> Self {
self.async_projection_poll_interval = interval;
self
}
pub fn snapshot_poll_interval(mut self, interval: std::time::Duration) -> Self {
self.snapshot_poll_interval = interval;
self
}
pub fn cross_context_route_poll_interval(mut self, interval: std::time::Duration) -> Self {
self.cross_context_route_poll_interval = interval;
self
}
pub fn cross_context_route_retry_policy(mut self, policy: skilj_retry::RetryPolicy) -> Self {
self.cross_context_route_retry_policy = policy;
self
}
pub fn deadline_retry_policy(mut self, policy: skilj_retry::RetryPolicy) -> Self {
self.deadline_retry_policy = policy;
self
}
pub fn deadline_poll_interval(mut self, interval: std::time::Duration) -> Self {
self.deadline_poll_interval = interval;
self
}
pub fn scheduler_poll_interval(mut self, interval: std::time::Duration) -> Self {
self.scheduler_poll_interval = interval;
self
}
pub fn projection_query_wait_timeout(mut self, timeout: std::time::Duration) -> Self {
self.projection_query_wait_timeout = timeout;
self
}
pub fn read_cursor_checkout_lease(mut self, lease: std::time::Duration) -> Self {
self.read_cursor_checkout_lease = lease;
self
}
pub fn max_events_per_read(mut self, max: usize) -> Self {
self.max_events_per_read = max.max(1);
self
}
pub fn graphql_limits(mut self, limits: skilj_graphql::limits::GraphqlLimits) -> Self {
self.graphql_limits = limits;
self
}
pub fn graphql_federation(
mut self,
options: skilj_graphql::federation::FederationOptions,
) -> Self {
self.graphql_federation = Some(options);
self
}
pub fn command_batch_idle_in_transaction_timeout(
mut self,
timeout: std::time::Duration,
) -> Self {
self.command_batch_idle_in_transaction_timeout = timeout;
self
}
pub fn command_batch_max_size(mut self, max_size: usize) -> Self {
self.command_batch_max_size = max_size;
self
}
pub fn max_items_per_batch(mut self, max_items: usize) -> Self {
self.max_items_per_batch = max_items.max(1);
self
}
pub fn command_batch_max_concurrent_leaders(mut self, max_leaders: usize) -> Self {
self.command_batch_max_concurrent_leaders = Some(max_leaders);
self
}
pub fn key_provider(self, provider: impl KeyProvider) -> Self {
self.key_service(KeyService::new(provider))
}
pub fn key_service(mut self, key_service: KeyService) -> Self {
self.key_service = Some(key_service);
self
}
pub fn legacy_encryption_master_key(mut self, master_key: EncryptionMasterKey) -> Self {
self.legacy_encryption_master_key = Some(master_key);
self
}
pub fn master_key_rotation(mut self, rotation: MasterKeyRotation) -> Self {
self.master_key_rotation = rotation;
self
}
pub fn event_broadcast_capacity(mut self, capacity: usize) -> Self {
self.event_broadcast_capacity = capacity;
self
}
pub fn event_cache_warm_up_count(mut self, count: usize) -> Self {
self.event_cache_warm_up_count = count;
self
}
pub fn idempotency_key_retention(mut self, retention: std::time::Duration) -> Self {
self.idempotency_key_retention = Some(retention);
self
}
pub fn application_version(mut self, version: u64) -> Self {
self.application_version = Some(version);
self
}
pub fn deadline_retention(mut self, retention: std::time::Duration) -> Self {
self.deadline_retention = Some(retention);
self
}
pub fn slow_command_read_warning(mut self, threshold: std::time::Duration) -> Self {
self.slow_command_read_warning = Some(threshold);
self
}
pub fn no_slow_command_read_warning(mut self) -> Self {
self.slow_command_read_warning = None;
self
}
pub fn slow_command_decide_warning(mut self, threshold: std::time::Duration) -> Self {
self.slow_command_decide_warning = Some(threshold);
self
}
pub fn no_slow_command_decide_warning(mut self) -> Self {
self.slow_command_decide_warning = None;
self
}
pub fn keep_resolved_deadlines_forever(mut self) -> Self {
self.deadline_retention = None;
self
}
pub fn keep_idempotency_keys_forever(mut self) -> Self {
self.idempotency_key_retention = None;
self
}
pub fn pool_options(mut self, options: PgPoolOptions) -> Self {
self.pool_options = Some(options);
self
}
pub fn statement_cache_capacity(mut self, capacity: usize) -> Self {
self.statement_cache_capacity = Some(capacity);
self
}
pub async fn build(self) -> Result<(Skilj, ReconciliationReport), skilj_core::Error> {
if let Some(options) = &self.pool_options {
let max_connections = options.get_max_connections();
if max_connections < MIN_POOL_CONNECTIONS {
return Err(skilj_core::Error::configuration(format!(
"max_connections is {max_connections}, at least \
{MIN_POOL_CONNECTIONS} needed: the cross-instance listener \
keeps one connection for the process's lifetime"
)));
}
}
validate_consistency_queries(&self.command_types, &self.event_types, &self.snapshots)?;
if !self.previous_blind_index_keys.is_empty() && self.blind_index_key.is_none() {
return Err(skilj_core::Error::configuration(
"previous_blind_index_keys is configured without a blind_index_key to rotate to",
));
}
self.master_key_rotation.validate()?;
if self.legacy_encryption_master_key.is_some() && self.key_service.is_none() {
return Err(skilj_core::Error::configuration(
"legacy_encryption_master_key is configured without a key_provider to rewrap \
its data keys with",
));
}
validate_blind_tags(
&self.blind_tags,
self.blind_index_key.is_some(),
&self.event_types,
&self.command_types,
&self.snapshots,
)?;
let graphql_naming = match &self.graphql_federation {
Some(options) => options.naming().map_err(skilj_core::Error::configuration)?,
None => skilj_graphql::naming::Naming::default(),
};
let graphql_federation = self.graphql_federation.map(Arc::new);
let pool = skilj_core::db::connect_with_statement_cache(
&self.database_url,
self.pool_options.unwrap_or_else(default_pool_options),
self.statement_cache_capacity,
)
.await?;
let statement_cache_capacity = skilj_core::db::effective_statement_cache_capacity(
&self.database_url,
self.statement_cache_capacity,
);
skilj_core::db::migrate(&pool).await?;
observe_epoch(&pool).await;
let existing_admin_context = skilj_core::db::get_bounded_context(
&pool,
skilj_core::bootstrap::ADMIN_BOUNDED_CONTEXT_NAME,
)
.await?;
if let Some(admin_context) = skilj_core::bootstrap::stamp_admin_bounded_context(
existing_admin_context.as_ref(),
chrono::Utc::now(),
) {
if let Err(e) = skilj_core::db::insert_bounded_context(&pool, &admin_context).await {
if skilj_core::db::get_bounded_context(
&pool,
skilj_core::bootstrap::ADMIN_BOUNDED_CONTEXT_NAME,
)
.await?
.is_none()
{
return Err(e);
}
}
}
let roles = skilj_core::db::list_roles(&pool).await?;
let bootstrap_secret = skilj_core::bootstrap::generate_bootstrap_secret(&roles);
let bootstrap = skilj_core::bootstrap::BootstrapGate::new(bootstrap_secret.clone());
let parked_delivery_retry_permits = Arc::new(tokio::sync::Semaphore::new(
(pool.options().get_max_connections() as usize / 2).max(1),
));
if let Some(secret) = &bootstrap_secret {
eprintln!(
"skilj: no active superadmin Role exists yet - bootstrap secret (use once, via \
the createSuperadmin GraphQL mutation): {}",
secret.secret
);
}
let projections = Arc::new(self.projections);
let command_types = Arc::new(self.command_types);
let snapshots = Arc::new(self.snapshots);
let bounded_contexts_for_warm_up = skilj_core::db::list_bounded_contexts(&pool).await?;
warn_on_small_statement_cache(statement_cache_capacity, &bounded_contexts_for_warm_up);
let template_cache = skilj_core::template_cache::TemplateCache::new();
template_cache.refresh(&pool).await?;
let blind_index = match &self.blind_index_key {
Some(key) => skilj_core::blind::BlindIndex::new(
key.clone(),
self.blind_tags
.iter()
.map(|((bc, tag_key), b)| (bc.clone(), tag_key.clone(), b.normalize)),
)
.with_previous_keys(self.previous_blind_index_keys.iter().cloned())
.with_templates(template_cache.clone()),
None => skilj_core::blind::BlindIndex::default(),
};
let identity_provider = self.identity_provider.map(|config| {
Arc::new(IdentityProvider {
cache: Arc::new(JwksCache::new(config.jwks_endpoint.clone())),
config,
})
});
let poll_interval = self.async_projection_poll_interval;
let projection_query_wait_timeout = self.projection_query_wait_timeout;
let read_cursor_checkout_lease = self.read_cursor_checkout_lease;
let max_events_per_read = self.max_events_per_read;
let graphql_limits = self.graphql_limits;
let key_service = match (self.key_service, self.legacy_encryption_master_key) {
(Some(service), Some(legacy)) => Some(service.with_legacy_master_key(legacy)),
(service, _) => service,
};
let master_key_rotation = self.master_key_rotation.clone();
let event_broadcaster = EventBroadcaster::new(self.event_broadcast_capacity);
let revocation_broadcaster = RevocationBroadcaster::new();
let event_cache = EventCache::new(self.event_cache_warm_up_count);
let command_batcher = skilj_core::command_batcher::CommandBatcher::new()
.with_idle_in_transaction_timeout(self.command_batch_idle_in_transaction_timeout)
.with_max_batch_size(self.command_batch_max_size)
.with_max_items_per_batch(self.max_items_per_batch)
.with_slow_read_warning(self.slow_command_read_warning)
.with_slow_decide_warning(self.slow_command_decide_warning);
let command_batcher = match self.command_batch_max_concurrent_leaders {
Some(n) => command_batcher.with_max_concurrent_leaders(n),
None => command_batcher,
};
stream::iter(bounded_contexts_for_warm_up)
.map(|bc| {
let pool = &pool;
let event_cache = &event_cache;
async move {
let prepared = async {
skilj_core::db::ensure_idempotency_keys_table(pool, &bc.name).await?;
skilj_core::db::migrate_idempotency_keys_client_id_scoping(pool, &bc.name)
.await?;
skilj_core::db::ensure_projection_state_owner_columns(pool, &bc.name)
.await?;
skilj_core::db::ensure_projection_state_as_of_sequence_columns(
pool, &bc.name,
)
.await?;
skilj_core::db::ensure_event_scoping_columns(pool, &bc.name).await?;
skilj_core::db::ensure_private_field_columns(pool, &bc.name).await?;
skilj_core::db::ensure_projection_requires_capability_column(
pool, &bc.name,
)
.await?;
skilj_core::db::ensure_private_field_grants_table(pool, &bc.name).await?;
skilj_core::db::ensure_command_approval_tables(pool, &bc.name).await?;
skilj_core::db::ensure_secret_tables(pool, &bc.name).await?;
skilj_core::db::ensure_cross_context_route_cursors_table(pool, &bc.name)
.await?;
skilj_core::db::ensure_deadline_cursors_table(pool, &bc.name).await?;
skilj_core::db::ensure_deadlines_table(pool, &bc.name).await?;
skilj_core::db::ensure_projection_partition_progress_table(pool, &bc.name)
.await?;
skilj_core::db::ensure_snapshot_partition_progress_table(pool, &bc.name)
.await?;
skilj_core::db::ensure_read_cursors_checkout_column(pool, &bc.name).await?;
skilj_core::db::ensure_external_message_cursors_table(pool, &bc.name)
.await?;
skilj_core::db::ensure_external_message_keys_table(pool, &bc.name).await?;
skilj_core::db::ensure_event_read_token_start_from_column(pool, &bc.name)
.await?;
skilj_core::db::ensure_correlation_causation_columns(pool, &bc.name)
.await?;
skilj_core::db::ensure_cross_context_route_retry_columns(pool, &bc.name)
.await?;
skilj_core::db::ensure_parked_deliveries_table(pool, &bc.name).await?;
skilj_core::db::migrate_parked_deliveries_dedup_and_unique_index(
pool, &bc.name,
)
.await?;
skilj_core::db::ensure_registrations_generation(pool, &bc.name).await?;
skilj_core::db::make_bounded_context_current(pool, &bc).await?;
event_cache.warm(pool, &bc.name).await?;
Ok::<(), skilj_core::Error>(())
}
.await;
match prepared {
Err(err)
if skilj_core::db::get_bounded_context(pool, &bc.name)
.await?
.is_none() =>
{
tracing::info!(
bounded_context = %bc.name,
error = %err,
"bounded context deleted during startup, skipped"
);
Ok(())
}
other => other,
}
}
})
.buffer_unordered(BACKGROUND_TASK_CONCURRENCY)
.try_collect::<Vec<()>>()
.await?;
let baselined = skilj_core::db::ensure_access_trail(&pool).await?;
if baselined > 0 {
tracing::info!(
grants = baselined,
"access trail: recorded the starting state of grants that had no history"
);
}
if let Some(key_service) = &key_service {
check_encryption_keys(&pool, key_service).await?;
}
let pending_rotations =
check_stored_blind_tags(&pool, &self.blind_tags, &blind_index, &template_cache).await?;
let mut report = ReconciliationReport::default();
if let Some(external_subject) = &self.reconciliation_role {
let role = skilj_core::access_control::resolve_role_by_external_subject(
external_subject,
&roles,
)?
.clone();
reconcile_blind_tags(&pool, &role, &self.blind_tags, &blind_index, &mut report).await?;
rotate_blind_tags(&pool, &role, pending_rotations, &blind_index, &mut report).await?;
let version = RegistrationVersion::new(self.application_version);
reconcile_event_types(
&pool,
&role,
&self.event_types,
&mut report,
chrono::Utc::now(),
&version,
)
.await?;
reconcile_command_types(&pool, &role, &command_types, &mut report, &version).await?;
let reconciliation_dispatcher = ProjectionDispatcherImpl {
projections: projections.clone(),
template_cache: template_cache.clone(),
};
reconcile_projections(
&pool,
&role,
&projections,
&mut report,
&reconciliation_dispatcher,
&version,
)
.await?;
} else {
for rotation in &pending_rotations {
tracing::warn!(
bounded_context = %rotation.bounded_context,
tag_key = %rotation.tag_key,
generation = rotation.generation,
"blind tag behind the current blind-index key; an instance with a \
reconciliation role rotates it at startup"
);
}
}
let schema_registry = Arc::new(
skilj_graphql::schema::SchemaRegistry::build(skilj_graphql::GraphqlState {
pool: pool.clone(),
parked_delivery_retry_permits: parked_delivery_retry_permits.clone(),
bootstrap: bootstrap.clone(),
identity: identity_provider
.as_ref()
.map(|ip| skilj_graphql::auth::Identity {
config: ip.config.clone(),
cache: Arc::clone(&ip.cache),
}),
dispatcher: Arc::new(Dispatcher {
command_types: command_types.clone(),
template_cache: template_cache.clone(),
snapshots: SnapshotDispatcherImpl {
snapshots: snapshots.clone(),
template_cache: template_cache.clone(),
},
blind_index: blind_index.clone(),
}),
projection_dispatcher: Arc::new(ProjectionDispatcherImpl {
projections: projections.clone(),
template_cache: template_cache.clone(),
}),
snapshot_dispatcher: Arc::new(SnapshotDispatcherImpl {
snapshots: snapshots.clone(),
template_cache: template_cache.clone(),
}),
projection_query_wait_timeout,
max_events_per_read,
limits: graphql_limits,
key_service: key_service.clone(),
event_broadcaster: event_broadcaster.clone(),
revocation_broadcaster: revocation_broadcaster.clone(),
event_cache: event_cache.clone(),
template_cache: template_cache.clone(),
command_batcher: command_batcher.clone(),
naming: graphql_naming.clone(),
federation: graphql_federation.clone(),
})
.await?,
);
if let Some(options) = &graphql_federation {
warn_unpublishable(&pool, options).await?;
}
let background = Background::new();
let skilj = Skilj {
pool,
command_types,
projections,
snapshots,
event_types: Arc::new(self.event_types),
bootstrap,
parked_delivery_retry_permits,
identity_provider,
projection_query_wait_timeout,
read_cursor_checkout_lease,
max_events_per_read,
graphql_limits,
graphql_federation,
graphql_naming,
key_service,
event_broadcaster,
revocation_broadcaster,
event_cache,
schema_registry,
template_cache,
command_batcher,
blind_index,
background: background.clone(),
};
let poll_pool = skilj.pool.clone();
let poll_dispatcher = skilj.projection_dispatcher();
background.spawn("async_projections", move |mut stop| async move {
loop {
let start = std::time::Instant::now();
async {
match skilj_core::db::list_bounded_contexts(&poll_pool).await {
Ok(bounded_contexts) => {
stream::iter(bounded_contexts)
.for_each_concurrent(BACKGROUND_TASK_CONCURRENCY, |bc| {
let poll_pool = poll_pool.clone();
let poll_dispatcher = poll_dispatcher.clone();
contain_panic("async_projection", bc.name.clone(), async move {
if let Err(e) = skilj_core::db::ensure_bounded_context_current(
&poll_pool, &bc,
)
.await
{
tracing::warn!(
bounded_context = %bc.name,
error = %e,
"bringing a bounded context's schema up to date failed"
);
}
if let Err(e) = skilj_core::db::catch_up_bounded_context(
&poll_pool,
&bc.name,
poll_dispatcher.as_ref(),
)
.await
{
tracing::warn!(
bounded_context = %bc.name,
error = %e,
"async projection catch-up failed"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "async_projection"),
KeyValue::new("reason", "catch_up_failed"),
],
);
}
})
})
.await;
}
Err(e) => {
tracing::warn!(
error = %e,
"async projection catch-up failed to list bounded contexts"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "async_projection"),
KeyValue::new("reason", "list_bounded_contexts_failed"),
],
);
}
}
}
.instrument(tracing::info_span!("async_projection_tick"))
.await;
BACKGROUND_TASK_TICK_DURATION.record(
start.elapsed().as_secs_f64(),
&[KeyValue::new("task", "async_projection")],
);
if stop.sleep(poll_interval).await {
return;
}
}
});
if let Some(retention) = self.idempotency_key_retention {
let retention_pool = skilj.pool.clone();
let cleanup_interval = retention
.min(IDEMPOTENCY_KEY_CLEANUP_INTERVAL)
.max(std::time::Duration::from_secs(1));
let startup_grace = retention.min(IDEMPOTENCY_KEY_STARTUP_GRACE);
let retention = chrono::Duration::from_std(retention).unwrap_or(chrono::Duration::MAX);
background.spawn("idempotency_key_retention", move |mut stop| async move {
if stop.sleep(startup_grace).await {
return;
}
loop {
let start = std::time::Instant::now();
retention_tick(&retention_pool, RetentionTarget::IdempotencyKeys, retention)
.instrument(tracing::info_span!("idempotency_key_retention_tick"))
.await;
BACKGROUND_TASK_TICK_DURATION.record(
start.elapsed().as_secs_f64(),
&[KeyValue::new("task", "idempotency_key_retention")],
);
if stop.sleep(cleanup_interval).await {
return;
}
}
});
}
if let Some(retention) = self.deadline_retention {
let retention_pool = skilj.pool.clone();
let cleanup_interval = retention
.min(DEADLINE_CLEANUP_INTERVAL)
.max(std::time::Duration::from_secs(1));
let retention = chrono::Duration::from_std(retention).unwrap_or(chrono::Duration::MAX);
background.spawn("deadline_retention", move |mut stop| async move {
loop {
let start = std::time::Instant::now();
retention_tick(
&retention_pool,
RetentionTarget::ResolvedDeadlines,
retention,
)
.instrument(tracing::info_span!("deadline_retention_tick"))
.await;
BACKGROUND_TASK_TICK_DURATION.record(
start.elapsed().as_secs_f64(),
&[KeyValue::new("task", "deadline_retention")],
);
if stop.sleep(cleanup_interval).await {
return;
}
}
});
}
let snapshot_pool = skilj.pool.clone();
let snapshot_dispatcher = skilj.snapshot_dispatcher();
let snapshot_interval = self.snapshot_poll_interval;
background.spawn("snapshots", move |mut stop| async move {
loop {
let start = std::time::Instant::now();
async {
match skilj_core::db::list_bounded_contexts(&snapshot_pool).await {
Ok(bounded_contexts) => {
stream::iter(bounded_contexts)
.for_each_concurrent(BACKGROUND_TASK_CONCURRENCY, |bc| {
let snapshot_pool = snapshot_pool.clone();
let snapshot_dispatcher = snapshot_dispatcher.clone();
contain_panic("snapshot", bc.name.clone(), async move {
if let Err(e) = skilj_core::db::catch_up_snapshots(
&snapshot_pool,
&bc.name,
snapshot_dispatcher.as_ref(),
)
.await
{
tracing::warn!(
bounded_context = %bc.name,
error = %e,
"snapshot catch-up failed"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "snapshot"),
KeyValue::new("reason", "catch_up_failed"),
],
);
}
})
})
.await;
}
Err(e) => {
tracing::warn!(
error = %e,
"snapshot catch-up failed to list bounded contexts"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "snapshot"),
KeyValue::new("reason", "list_bounded_contexts_failed"),
],
);
}
}
}
.instrument(tracing::info_span!("snapshot_tick"))
.await;
BACKGROUND_TASK_TICK_DURATION.record(
start.elapsed().as_secs_f64(),
&[KeyValue::new("task", "snapshot")],
);
if stop.sleep(snapshot_interval).await {
return;
}
}
});
let route_pool = skilj.pool.clone();
let route_command_dispatcher = skilj.command_dispatcher();
let route_projection_dispatcher = skilj.projection_dispatcher();
let route_snapshot_dispatcher = skilj.snapshot_dispatcher();
let route_broadcaster = skilj.event_broadcaster.clone();
let route_event_cache = skilj.event_cache.clone();
let route_encryption_master_key = skilj.key_service.clone();
let route_dispatcher: Arc<dyn skilj_core::plugin::CrossContextRouteDispatcher> =
Arc::new(CrossContextRouteDispatcherImpl {
routes: Arc::new(self.cross_context_routes),
});
let routes = route_dispatcher.routes();
let route_interval = self.cross_context_route_poll_interval;
let route_retry_policy = self.cross_context_route_retry_policy;
let route_concurrency = (route_pool.options().get_max_connections() as usize / 2)
.clamp(1, BACKGROUND_TASK_CONCURRENCY);
background.spawn("cross_context_routes", move |mut stop| async move {
loop {
let start = std::time::Instant::now();
async {
stream::iter(routes.clone())
.for_each_concurrent(route_concurrency, |route| {
let route_pool = route_pool.clone();
let route_dispatcher = route_dispatcher.clone();
let route_command_dispatcher = route_command_dispatcher.clone();
let route_projection_dispatcher = route_projection_dispatcher.clone();
let route_snapshot_dispatcher = route_snapshot_dispatcher.clone();
let route_broadcaster = route_broadcaster.clone();
let route_event_cache = route_event_cache.clone();
let route_encryption_master_key = route_encryption_master_key.clone();
contain_panic(
"cross_context_route",
route.name.to_string(),
async move {
if let Err(e) = skilj_core::db::catch_up_cross_context_route(
&route_pool,
&route,
route_dispatcher.as_ref(),
route_command_dispatcher.as_ref(),
route_projection_dispatcher.as_ref(),
route_snapshot_dispatcher.as_ref(),
&route_broadcaster,
&route_event_cache,
route_encryption_master_key.as_ref(),
&route_retry_policy,
)
.await
{
tracing::warn!(
route = %route.name,
error = %e,
"cross-context route catch-up failed"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "cross_context_route"),
KeyValue::new("reason", "catch_up_failed"),
],
);
}
},
)
})
.await;
}
.instrument(tracing::info_span!("cross_context_route_tick"))
.await;
BACKGROUND_TASK_TICK_DURATION.record(
start.elapsed().as_secs_f64(),
&[KeyValue::new("task", "cross_context_route")],
);
if stop.sleep(route_interval).await {
return;
}
}
});
let deadline_interval = self.deadline_poll_interval;
let schedule_deadline_pool = skilj.pool.clone();
let schedule_deadline_event_cache = skilj.event_cache.clone();
let schedule_deadline_dispatcher: Arc<dyn skilj_core::plugin::ScheduleDeadlineDispatcher> =
Arc::new(ScheduleDeadlineDispatcherImpl {
schedules: Arc::new(self.schedule_deadlines),
});
let schedules = schedule_deadline_dispatcher.schedules();
let scheduled: Vec<(&'static str, &'static str)> = schedules
.iter()
.map(|schedule| (schedule.name, schedule.source_bounded_context))
.collect();
background.spawn("schedule_deadlines", move |mut stop| async move {
loop {
let start = std::time::Instant::now();
async {
stream::iter(schedules.clone())
.for_each_concurrent(BACKGROUND_TASK_CONCURRENCY, |schedule| {
let schedule_deadline_pool = schedule_deadline_pool.clone();
let schedule_deadline_dispatcher = schedule_deadline_dispatcher.clone();
let schedule_deadline_event_cache =
schedule_deadline_event_cache.clone();
contain_panic(
"schedule_deadline",
schedule.name.to_string(),
async move {
if let Err(e) = skilj_core::db::catch_up_schedule_deadline(
&schedule_deadline_pool,
&schedule,
schedule_deadline_dispatcher.as_ref(),
&schedule_deadline_event_cache,
)
.await
{
tracing::warn!(
schedule = %schedule.name,
error = %e,
"schedule deadline catch-up failed"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "schedule_deadline"),
KeyValue::new("reason", "catch_up_failed"),
],
);
}
},
)
})
.await;
}
.instrument(tracing::info_span!("schedule_deadline_tick"))
.await;
BACKGROUND_TASK_TICK_DURATION.record(
start.elapsed().as_secs_f64(),
&[KeyValue::new("task", "schedule_deadline")],
);
if stop.sleep(deadline_interval).await {
return;
}
}
});
let cancel_deadline_pool = skilj.pool.clone();
let cancel_deadline_event_cache = skilj.event_cache.clone();
let cancel_deadline_dispatcher: Arc<dyn skilj_core::plugin::CancelDeadlineDispatcher> =
Arc::new(CancelDeadlineDispatcherImpl {
cancels: Arc::new(self.cancel_deadlines),
});
let cancels = cancel_deadline_dispatcher.cancels();
let fire_cancels = cancels.clone();
for cancel in &cancels {
let scheduled_here = scheduled.contains(&(
cancel.deadline_schedule_name,
cancel.deadline_schedule_bounded_context,
));
if !scheduled_here {
tracing::warn!(
cancel = cancel.name,
schedule = cancel.deadline_schedule_name,
"a cancel deadline's schedule isn't registered in this process - its \
cancels wait until something runs that schedule"
);
}
}
background.spawn("cancel_deadlines", move |mut stop| async move {
loop {
let start = std::time::Instant::now();
async {
stream::iter(cancels.clone())
.for_each_concurrent(BACKGROUND_TASK_CONCURRENCY, |cancel| {
let cancel_deadline_pool = cancel_deadline_pool.clone();
let cancel_deadline_dispatcher = cancel_deadline_dispatcher.clone();
let cancel_deadline_event_cache = cancel_deadline_event_cache.clone();
contain_panic("cancel_deadline", cancel.name.to_string(), async move {
if let Err(e) = skilj_core::db::catch_up_cancel_deadline(
&cancel_deadline_pool,
&cancel,
cancel_deadline_dispatcher.as_ref(),
&cancel_deadline_event_cache,
)
.await
{
tracing::warn!(
cancel = %cancel.name,
error = %e,
"cancel deadline catch-up failed"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "cancel_deadline"),
KeyValue::new("reason", "catch_up_failed"),
],
);
}
})
})
.await;
}
.instrument(tracing::info_span!("cancel_deadline_tick"))
.await;
BACKGROUND_TASK_TICK_DURATION.record(
start.elapsed().as_secs_f64(),
&[KeyValue::new("task", "cancel_deadline")],
);
if stop.sleep(deadline_interval).await {
return;
}
}
});
let deadline_fire_pool = skilj.pool.clone();
let deadline_fire_command_dispatcher = skilj.command_dispatcher();
let deadline_fire_projection_dispatcher = skilj.projection_dispatcher();
let deadline_fire_snapshot_dispatcher = skilj.snapshot_dispatcher();
let deadline_fire_broadcaster = skilj.event_broadcaster.clone();
let deadline_fire_event_cache = skilj.event_cache.clone();
let deadline_fire_encryption_master_key = skilj.key_service.clone();
let deadline_retry_policy = self.deadline_retry_policy;
background.spawn("fire_deadlines", move |mut stop| async move {
let fire_cancels = fire_cancels;
loop {
let start = std::time::Instant::now();
deadline_fire_tick(
&deadline_fire_pool,
deadline_fire_command_dispatcher.as_ref(),
deadline_fire_projection_dispatcher.as_ref(),
deadline_fire_snapshot_dispatcher.as_ref(),
&deadline_fire_broadcaster,
&deadline_fire_event_cache,
deadline_fire_encryption_master_key.as_ref(),
chrono::Utc::now(),
&deadline_retry_policy,
&fire_cancels,
)
.instrument(tracing::info_span!("deadline_fire_tick"))
.await;
BACKGROUND_TASK_TICK_DURATION.record(
start.elapsed().as_secs_f64(),
&[KeyValue::new("task", "deadline_fire")],
);
if stop.sleep(deadline_interval).await {
return;
}
}
});
if let Some(key_service) = skilj.key_service.clone() {
let rotation_pool = skilj.pool.clone();
let rotation = master_key_rotation.clone();
background.spawn("master_key_rotation", move |mut stop| async move {
loop {
if let Err(err) = master_key_tick(&rotation_pool, &key_service, &rotation)
.instrument(tracing::info_span!("master_key_tick"))
.await
{
tracing::warn!(error = %err, "master key lifecycle check failed; retrying");
}
if stop.sleep(rotation.check_interval).await {
return;
}
}
});
}
let scheduler_pool = skilj.pool.clone();
let scheduler_projection_dispatcher = skilj.projection_dispatcher();
let scheduler_event_dispatcher = skilj.event_dispatcher();
let scheduler_broadcaster = skilj.event_broadcaster.clone();
let scheduler_event_cache = skilj.event_cache.clone();
let scheduler_encryption_master_key = skilj.key_service.clone();
let scheduler_interval = self.scheduler_poll_interval;
background.spawn("system_event_scheduler", move |mut stop| async move {
loop {
let start = std::time::Instant::now();
scheduler_tick(
&scheduler_pool,
scheduler_projection_dispatcher.as_ref(),
scheduler_event_dispatcher.as_ref(),
&scheduler_broadcaster,
&scheduler_event_cache,
scheduler_encryption_master_key.as_ref(),
chrono::Utc::now(),
)
.instrument(tracing::info_span!("scheduler_tick"))
.await;
BACKGROUND_TASK_TICK_DURATION.record(
start.elapsed().as_secs_f64(),
&[KeyValue::new("task", "scheduler")],
);
if stop.sleep(scheduler_interval).await {
return;
}
}
});
let cross_instance_pool = skilj.pool.clone();
let cross_instance_event_broadcaster = skilj.event_broadcaster.clone();
let cross_instance_revocation_broadcaster = skilj.revocation_broadcaster.clone();
let cross_instance_schema_registry = Arc::clone(&skilj.schema_registry);
let cross_instance_template_cache = skilj.template_cache.clone();
let cross_instance_state = skilj.graphql_state();
background.spawn("cross_instance", move |mut stop| async move {
let mut listener = loop {
match skilj_core::cross_instance::Listener::connect(&cross_instance_pool).await {
Ok(listener) => break listener,
Err(err) => {
tracing::warn!(
error = %err,
"cross-instance listener failed to connect, retrying"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "cross_instance"),
KeyValue::new("reason", "connect_failed"),
],
);
if stop.sleep(std::time::Duration::from_secs(5)).await {
return;
}
}
}
};
loop {
let received = tokio::select! {
() = stop.stopped() => return,
received = listener
.recv()
.instrument(tracing::info_span!("cross_instance_recv")) => received,
};
let message = match received {
Ok(message) => message,
Err(err) => {
tracing::warn!(error = %err, "cross-instance listener error, retrying");
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "cross_instance"),
KeyValue::new("reason", "recv_failed"),
],
);
if stop.sleep(std::time::Duration::from_secs(1)).await {
return;
}
continue;
}
};
match message {
skilj_core::cross_instance::Message::EventAppended {
bounded_context,
sequence,
origin_instance_id,
} => {
if origin_instance_id == cross_instance_event_broadcaster.instance_id() {
continue;
}
match skilj_core::db::get_event_by_sequence(
&cross_instance_pool,
&bounded_context,
sequence,
)
.await
{
Ok(Some(event)) => cross_instance_event_broadcaster.publish(&event),
Ok(None) => {}
Err(err) => tracing::warn!(
error = %err,
bounded_context = %bounded_context,
sequence,
"cross-instance event refetch failed"
),
}
}
skilj_core::cross_instance::Message::Revoked {
revoked,
origin_instance_id,
} => {
if origin_instance_id != cross_instance_revocation_broadcaster.instance_id()
{
cross_instance_revocation_broadcaster.publish(revoked);
}
}
message => {
if !matches!(
message,
skilj_core::cross_instance::Message::RegistrationChanged
| skilj_core::cross_instance::Message::Resync
) {
tracing::warn!(
"cross-instance notification this build does not support - \
treating it as a resync; update all skilj crates to the same version"
);
}
if let Err(err) = cross_instance_schema_registry
.rebuild(cross_instance_state.clone())
.await
{
tracing::warn!(error = %err, "cross-instance schema rebuild failed");
}
if let Err(err) = cross_instance_template_cache
.refresh(&cross_instance_pool)
.await
{
tracing::warn!(
error = %err,
"cross-instance template cache refresh failed"
);
}
if !matches!(
message,
skilj_core::cross_instance::Message::RegistrationChanged
) {
observe_epoch(&cross_instance_pool).await;
cross_instance_event_broadcaster.signal_gap();
}
}
}
}
});
Ok((skilj, report))
}
}
static EPOCH_CHANGES: LazyLock<Counter<u64>> = LazyLock::new(|| {
opentelemetry::global::meter("skilj")
.u64_counter("skilj.database.epoch_changes")
.with_description(
"Database epoch changes (a promotion or point-in-time restore) skilj noticed.",
)
.build()
});
async fn observe_epoch(pool: &Pool) {
let observed = async {
let epoch = skilj_core::db::current_epoch(pool).await?;
let previous = skilj_core::db::record_epoch(pool, &epoch, chrono::Utc::now()).await?;
Ok::<_, skilj_core::Error>((epoch, previous))
}
.await;
match observed {
Ok((epoch, Some(previous))) => {
EPOCH_CHANGES.add(1, &[]);
tracing::error!(
%previous,
current = %epoch,
"the database's epoch changed (a standby was promoted, or it was restored): \
events committed on the old one may be gone and their sequences reused - \
positions held outside the database from before are refused with \
epoch_changed, and anything already published or acknowledged to a \
broker from the lost tail needs checking"
);
}
Ok((_, None)) => {}
Err(err) => {
tracing::warn!(error = %err, "reading the database's epoch failed");
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "cross_instance"),
KeyValue::new("reason", "epoch_failed"),
],
);
}
}
}
#[derive(Clone, Copy)]
enum RetentionTarget {
IdempotencyKeys,
ResolvedDeadlines,
}
impl RetentionTarget {
fn task(self) -> &'static str {
match self {
RetentionTarget::IdempotencyKeys => "idempotency_key_retention",
RetentionTarget::ResolvedDeadlines => "deadline_retention",
}
}
async fn delete_batch(
self,
pool: &Pool,
bounded_context: &str,
cutoff: chrono::DateTime<chrono::Utc>,
) -> skilj_core::error::Result<u64> {
match self {
RetentionTarget::IdempotencyKeys => {
let commands = skilj_core::db::delete_expired_idempotency_keys(
pool,
bounded_context,
cutoff,
IDEMPOTENCY_KEY_CLEANUP_BATCH,
)
.await?;
let external = skilj_core::db::delete_expired_external_message_keys(
pool,
bounded_context,
cutoff,
IDEMPOTENCY_KEY_CLEANUP_BATCH,
)
.await?;
Ok(commands + external)
}
RetentionTarget::ResolvedDeadlines => {
skilj_core::db::delete_expired_deadlines(
pool,
bounded_context,
cutoff,
IDEMPOTENCY_KEY_CLEANUP_BATCH,
)
.await
}
}
}
}
async fn retention_tick(pool: &Pool, target: RetentionTarget, retention: chrono::Duration) {
let task = target.task();
let cutoff = chrono::Utc::now()
.checked_sub_signed(retention)
.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC);
let bounded_contexts = match skilj_core::db::list_bounded_contexts(pool).await {
Ok(bcs) => bcs,
Err(e) => {
tracing::warn!(error = %e, task, "retention failed to list bounded contexts");
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", task),
KeyValue::new("reason", "list_bounded_contexts_failed"),
],
);
return;
}
};
stream::iter(&bounded_contexts)
.for_each_concurrent(BACKGROUND_TASK_CONCURRENCY, |bc| {
contain_panic(task, bc.name.clone(), async move {
loop {
match target.delete_batch(pool, &bc.name, cutoff).await {
Ok(deleted) if deleted < IDEMPOTENCY_KEY_CLEANUP_BATCH as u64 => break,
Ok(_) => continue,
Err(e) => {
tracing::warn!(
bounded_context = %bc.name,
error = %e,
task,
"retention failed"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", task),
KeyValue::new("reason", "delete_failed"),
],
);
break;
}
}
}
})
})
.await;
}
#[allow(clippy::too_many_arguments)]
async fn deadline_fire_tick(
pool: &Pool,
command_dispatcher: &dyn CommandDispatcher,
projection_dispatcher: &dyn skilj_core::plugin::ProjectionDispatcher,
snapshot_dispatcher: &dyn skilj_core::plugin::SnapshotDispatcher,
broadcaster: &EventBroadcaster,
event_cache: &EventCache,
key_service: Option<&KeyService>,
now: chrono::DateTime<chrono::Utc>,
retry_policy: &skilj_retry::RetryPolicy,
cancels: &[skilj_core::plugin::CancelDeadlineInfo],
) {
let bounded_contexts = match skilj_core::db::list_bounded_contexts(pool).await {
Ok(bcs) => bcs,
Err(e) => {
tracing::warn!(error = %e, "deadline fire tick failed to list bounded contexts");
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "deadline_fire"),
KeyValue::new("reason", "list_bounded_contexts_failed"),
],
);
return;
}
};
stream::iter(&bounded_contexts)
.for_each_concurrent(BACKGROUND_TASK_CONCURRENCY, |bc| {
contain_panic("deadline_fire", bc.name.clone(), async move {
if bc.status != skilj_core::event_store::BoundedContextStatus::Active {
return;
}
if let Err(e) = skilj_core::db::fire_due_deadlines(
pool,
command_dispatcher,
projection_dispatcher,
snapshot_dispatcher,
broadcaster,
event_cache,
&bc.name,
now,
key_service,
retry_policy,
cancels,
)
.await
{
tracing::warn!(
bounded_context = %bc.name,
error = %e,
"deadline fire tick failed"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "deadline_fire"),
KeyValue::new("reason", "fire_due_deadlines_failed"),
],
);
}
})
})
.await;
}
const MAX_OCCURRENCES_PER_TICK: usize = 1000;
async fn scheduler_tick(
pool: &Pool,
projection_dispatcher: &dyn skilj_core::plugin::ProjectionDispatcher,
event_dispatcher: &dyn skilj_core::plugin::EventDispatcher,
broadcaster: &EventBroadcaster,
event_cache: &EventCache,
key_service: Option<&KeyService>,
now: chrono::DateTime<chrono::Utc>,
) {
let bounded_contexts = match skilj_core::db::list_bounded_contexts(pool).await {
Ok(bcs) => bcs,
Err(e) => {
tracing::warn!(error = %e, "scheduler failed to list bounded contexts");
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "scheduler"),
KeyValue::new("reason", "list_bounded_contexts_failed"),
],
);
return;
}
};
stream::iter(&bounded_contexts)
.for_each_concurrent(BACKGROUND_TASK_CONCURRENCY, |bc| {
contain_panic(
"scheduler",
bc.name.clone(),
scheduler_tick_for_bounded_context(
pool,
projection_dispatcher,
event_dispatcher,
broadcaster,
event_cache,
key_service,
now,
bc,
),
)
})
.await;
}
#[allow(clippy::too_many_arguments)]
async fn scheduler_tick_for_bounded_context(
pool: &Pool,
projection_dispatcher: &dyn skilj_core::plugin::ProjectionDispatcher,
event_dispatcher: &dyn skilj_core::plugin::EventDispatcher,
broadcaster: &EventBroadcaster,
event_cache: &EventCache,
key_service: Option<&KeyService>,
now: chrono::DateTime<chrono::Utc>,
bc: &skilj_core::event_store::BoundedContext,
) {
if bc.status != skilj_core::event_store::BoundedContextStatus::Active {
return;
}
let scheduled = match skilj_core::db::list_scheduled_event_types(pool, &bc.name).await {
Ok(scheduled) => scheduled,
Err(e) => {
tracing::warn!(
bounded_context = %bc.name,
error = %e,
"scheduler failed to list scheduled event types"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "scheduler"),
KeyValue::new("reason", "list_scheduled_event_types_failed"),
],
);
return;
}
};
for et in &scheduled {
let (Some(schedule), Some(policy), Some(initial_position)) = (
&et.system_triggered_schedule,
et.missed_occurrence_policy,
et.schedule_position,
) else {
continue;
};
if policy == skilj_core::event_store::MissedOccurrencePolicy::FireOnce {
let Some(last_due) =
skilj_core::event_store::latest_occurrence_at_or_before(schedule, now)
.filter(|last| *last > initial_position)
else {
continue;
};
if let Err(e) = skilj_core::db::fire_system_event(
pool,
projection_dispatcher,
event_dispatcher,
broadcaster,
event_cache,
&bc.name,
&et.name,
last_due,
now,
key_service,
)
.await
{
tracing::warn!(
bounded_context = %bc.name,
event_type = %et.name,
error = %e,
"CreateSystemEvent failed"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "scheduler"),
KeyValue::new("reason", "create_system_event_failed"),
],
);
}
continue;
}
let mut cursor = initial_position;
for _ in 0..MAX_OCCURRENCES_PER_TICK {
let Some(occurrence_at) =
skilj_core::event_store::next_occurrence_after(schedule, cursor)
else {
break;
};
if occurrence_at > now {
break;
}
let nothing_later_is_due =
skilj_core::event_store::next_occurrence_after(schedule, occurrence_at)
.is_none_or(|next| next > now);
if policy == skilj_core::event_store::MissedOccurrencePolicy::Skip
&& !nothing_later_is_due
{
if let Err(e) = skilj_core::db::skip_missed_occurrences_for_event_type(
pool, &bc.name, &et.name, now,
)
.await
{
tracing::warn!(
bounded_context = %bc.name,
event_type = %et.name,
error = %e,
"SkipMissedOccurrences failed"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "scheduler"),
KeyValue::new("reason", "skip_missed_occurrences_failed"),
],
);
}
break;
}
if let Err(e) = skilj_core::db::fire_system_event(
pool,
projection_dispatcher,
event_dispatcher,
broadcaster,
event_cache,
&bc.name,
&et.name,
occurrence_at,
now,
key_service,
)
.await
{
tracing::warn!(
bounded_context = %bc.name,
event_type = %et.name,
error = %e,
"CreateSystemEvent failed"
);
BACKGROUND_TASK_ERRORS.add(
1,
&[
KeyValue::new("task", "scheduler"),
KeyValue::new("reason", "create_system_event_failed"),
],
);
break;
}
cursor = occurrence_at;
}
}
}
async fn active_admin_mapping(
pool: &Pool,
role_id: &str,
bounded_context: &str,
) -> skilj_core::error::Result<Option<skilj_core::access_control::RoleAccessMapping>> {
Ok(
skilj_core::db::get_active_role_access_mapping(pool, role_id, bounded_context)
.await?
.filter(|m| m.level == AccessLevel::Admin),
)
}
async fn resolve_bounded_context_and_admin_mapping(
pool: &Pool,
role_id: &str,
bounded_context_name: &str,
) -> skilj_core::error::Result<
Option<(
skilj_core::event_store::BoundedContext,
skilj_core::access_control::RoleAccessMapping,
)>,
> {
let Some(bc) = skilj_core::db::get_bounded_context(pool, bounded_context_name).await? else {
return Ok(None);
};
let Some(mapping) = active_admin_mapping(pool, role_id, bounded_context_name).await? else {
return Ok(None);
};
Ok(Some((bc, mapping)))
}
async fn reconcile_event_types(
pool: &Pool,
role: &Role,
event_types: &HashMap<(String, String), RegisteredEventType>,
report: &mut ReconciliationReport,
now: chrono::DateTime<chrono::Utc>,
version: &RegistrationVersion,
) -> Result<(), skilj_core::Error> {
for ((bounded_context_name, name), registered) in event_types {
let key = format!("{bounded_context_name}/{name}");
let Some((bc, mapping)) =
resolve_bounded_context_and_admin_mapping(pool, &role.id, bounded_context_name).await?
else {
report.skipped_no_access.push(key);
continue;
};
let existing = skilj_core::db::get_event_type(pool, bounded_context_name, name).await?;
if existing.is_some()
&& version
.is_older_than_stored(
pool,
bounded_context_name,
skilj_core::db::RegistrationTable::EventTypes,
name,
)
.await?
{
tracing::warn!(
event_type = %key,
"a newer application version registered this EventType - keeping its registration"
);
report.kept_newer.push(key);
continue;
}
let (sensitive_fields, private_fields, owner_tag_key, kept_protections) = match &existing {
Some(stored) => keep_stored_protections(
®istered.sensitive_fields,
®istered.private_fields,
®istered.owner_tag_key,
&stored.sensitive_fields,
&stored.private_fields,
&stored.owner_tag_key,
),
None => (
registered.sensitive_fields.clone(),
registered.private_fields.clone(),
registered.owner_tag_key.clone(),
false,
),
};
let register = |existing: Option<&skilj_core::event_store::EventType>| {
skilj_core::event_store::register_event_type(
&mapping,
&bc,
name.clone(),
registered.schema.clone(),
registered.tag_mappings.clone(),
owner_tag_key.clone(),
sensitive_fields.clone(),
private_fields.clone(),
registered.external_creation_allowed,
registered.direct_creation_allowed,
registered.system_triggered_allowed,
registered.system_triggered_schedule.clone(),
registered.missed_occurrence_policy,
registered.event_read_allowed,
existing,
now,
)
};
let registration = match (register(existing.as_ref()), existing.as_ref()) {
(Ok(registration), _) => registration,
(Err(e), Some(stored)) if is_older_than_stored_rejection(&e) => {
let ours = register(None)?;
let stored_evolves_ours = skilj_core::event_store::register_event_type(
&mapping,
&bc,
name.clone(),
stored.schema.clone(),
stored.tag_mappings.clone(),
stored.owner_tag_key.clone(),
stored.sensitive_fields.clone(),
stored.private_fields.clone(),
stored.external_creation_allowed,
stored.direct_creation_allowed,
stored.system_triggered_allowed,
stored.system_triggered_schedule.clone(),
stored.missed_occurrence_policy,
stored.event_read_allowed,
Some(ours.event_type()),
now,
)
.is_ok();
if !stored_evolves_ours {
return Err(e);
}
tracing::warn!(
event_type = %key,
"this process declares an older shape of this EventType than the stored one \
(a newer version registered it) - keeping the stored registration"
);
report.kept_newer.push(key);
continue;
}
(Err(e), _) => return Err(e),
};
if kept_protections {
tracing::warn!(
event_type = %key,
"this process's EventType declares fewer protections (sensitive/private \
fields, owner tag key) than the stored registration - keeping the stored ones"
);
report.kept_protections.push(key.clone());
}
skilj_core::db::upsert_event_type(pool, registration.event_type()).await?;
version
.stamp(
pool,
bounded_context_name,
skilj_core::db::RegistrationTable::EventTypes,
name,
)
.await?;
report.registered.push(key);
}
Ok(())
}
async fn reconcile_command_types(
pool: &Pool,
role: &Role,
command_types: &HashMap<(String, String), RegisteredCommandType>,
report: &mut ReconciliationReport,
version: &RegistrationVersion,
) -> Result<(), skilj_core::Error> {
for ((bounded_context_name, name), registered) in command_types {
let key = format!("{bounded_context_name}/{name}");
let Some((bc, mapping)) =
resolve_bounded_context_and_admin_mapping(pool, &role.id, bounded_context_name).await?
else {
report.skipped_no_access.push(key);
continue;
};
let existing = skilj_core::db::get_command_type(pool, bounded_context_name, name).await?;
if existing.is_some()
&& version
.is_older_than_stored(
pool,
bounded_context_name,
skilj_core::db::RegistrationTable::CommandTypes,
name,
)
.await?
{
tracing::warn!(
command_type = %key,
"a newer application version registered this CommandType - keeping its registration"
);
report.kept_newer.push(key);
continue;
}
let (sensitive_fields, private_fields, owner_tag_key, kept_protections) = match &existing {
Some(stored) => keep_stored_protections(
®istered.sensitive_fields,
®istered.private_fields,
®istered.owner_tag_key,
&stored.sensitive_fields,
&stored.private_fields,
&stored.owner_tag_key,
),
None => (
registered.sensitive_fields.clone(),
registered.private_fields.clone(),
registered.owner_tag_key.clone(),
false,
),
};
let register = |existing: Option<&skilj_core::event_store::CommandType>| {
skilj_core::event_store::register_command_type(
&mapping,
&bc,
name.clone(),
registered.schema.clone(),
registered.tag_mappings.clone(),
owner_tag_key.clone(),
sensitive_fields.clone(),
private_fields.clone(),
registered.rest_trigger_allowed,
existing,
)
};
let registration = match (register(existing.as_ref()), existing.as_ref()) {
(Ok(registration), _) => registration,
(Err(e), Some(stored)) if is_older_than_stored_rejection(&e) => {
let ours = register(None)?;
let stored_evolves_ours = skilj_core::event_store::register_command_type(
&mapping,
&bc,
name.clone(),
stored.schema.clone(),
stored.tag_mappings.clone(),
stored.owner_tag_key.clone(),
stored.sensitive_fields.clone(),
stored.private_fields.clone(),
stored.rest_trigger_allowed,
Some(ours.command_type()),
)
.is_ok();
if !stored_evolves_ours {
return Err(e);
}
tracing::warn!(
command_type = %key,
"this process declares an older shape of this CommandType than the stored \
one (a newer version registered it) - keeping the stored registration"
);
report.kept_newer.push(key);
continue;
}
(Err(e), _) => return Err(e),
};
if kept_protections {
tracing::warn!(
command_type = %key,
"this process's CommandType declares fewer protections (sensitive/private \
fields, owner tag key) than the stored registration - keeping the stored ones"
);
report.kept_protections.push(key.clone());
}
skilj_core::db::upsert_command_type(pool, registration.command_type()).await?;
if let Some(policy) = ®istered.approval_policy {
skilj_core::db::set_command_approval_policy(
pool,
bounded_context_name,
name,
Some(policy),
)
.await?;
}
version
.stamp(
pool,
bounded_context_name,
skilj_core::db::RegistrationTable::CommandTypes,
name,
)
.await?;
report.registered.push(key);
}
Ok(())
}
async fn resolve_consumed_event_types(
pool: &Pool,
bounded_context: &str,
names: &[&'static str],
) -> skilj_core::error::Result<Option<Vec<skilj_core::event_store::EventType>>> {
let mut event_types = Vec::with_capacity(names.len());
for name in names {
let Some(et) = skilj_core::db::get_event_type(pool, bounded_context, name).await? else {
return Ok(None);
};
event_types.push(et);
}
Ok(Some(event_types))
}
async fn reconcile_projections(
pool: &Pool,
role: &Role,
projections: &HashMap<(String, String), RegisteredProjection>,
report: &mut ReconciliationReport,
dispatcher: &dyn skilj_core::plugin::ProjectionDispatcher,
version: &RegistrationVersion,
) -> Result<(), skilj_core::Error> {
for ((bounded_context_name, name), registered) in projections {
let key = format!("{bounded_context_name}/{name}");
let Some((bc, mapping)) =
resolve_bounded_context_and_admin_mapping(pool, &role.id, bounded_context_name).await?
else {
report.skipped_no_access.push(key);
continue;
};
let Some(consumed_event_types) = resolve_consumed_event_types(
pool,
bounded_context_name,
®istered.consumed_event_types,
)
.await?
else {
report.skipped_no_access.push(key);
continue;
};
let private = skilj_core::projections::private_fields_consumed(&consumed_event_types);
if !private.is_empty() {
let fields: Vec<String> = private
.iter()
.map(|(event_type, field)| format!("{event_type}.{field}"))
.collect();
tracing::warn!(
projection = %key,
private_fields = ?fields,
requires_capability = ?registered.requires_capability,
"this Projection consumes event types with private fields - they reach \
project() unredacted, and anything of them it keeps in its state is visible \
to every reader of the projection, whatever their private-field grants. \
Leave them out of the state, or gate the projection with requires_capability"
);
}
let existing = skilj_core::db::get_projection(pool, bounded_context_name, name).await?;
if existing.is_some()
&& version
.is_older_than_stored(
pool,
bounded_context_name,
skilj_core::db::RegistrationTable::Projections,
name,
)
.await?
{
tracing::warn!(
projection = %key,
"a newer application version registered this Projection - keeping its registration"
);
report.kept_newer.push(key);
continue;
}
let staged = skilj_core::db::get_projection_rebuild(
pool,
bounded_context_name,
name,
ProjectionRebuildStatus::Pending,
)
.await?;
let bounded_context_events = skilj_core::db::witness_events_of_types(
pool,
bounded_context_name,
&consumed_event_types,
)
.await?;
let registration = match skilj_core::projections::register_projection(
&mapping,
&bc,
name.clone(),
registered.schema.clone(),
consumed_event_types,
registered.sync,
registered.requires_capability.map(str::to_owned),
existing.as_ref(),
staged.as_ref(),
&bounded_context_events,
) {
Ok(registration) => registration,
Err(e)
if is_older_than_stored_rejection(&e)
&& existing.as_ref().is_some_and(|stored| {
skilj_core::event_store::schema_is_backwards_compatible(
®istered.schema,
&stored.schema,
)
}) =>
{
tracing::warn!(
projection = %key,
"this process declares an older state schema for this Projection than the \
stored one (a newer version registered it) - keeping the stored projection"
);
report.kept_newer.push(key);
continue;
}
Err(e) => return Err(e),
};
match registration {
ProjectionRegistration::Created {
projection,
needs_history_fold,
} => {
skilj_core::db::create_projection(
pool,
&projection,
needs_history_fold,
dispatcher,
)
.await?;
}
ProjectionRegistration::ReconciledTrivially(projection) => {
skilj_core::db::upsert_projection(pool, &projection).await?;
}
ProjectionRegistration::RebuildStaged(rebuild) => {
skilj_core::db::set_projection_requires_capability(
pool,
bounded_context_name,
name,
registered.requires_capability,
)
.await?;
skilj_core::db::upsert_projection_rebuild(pool, &rebuild).await?;
}
_ => {
return Err(skilj_core::error::Error::Unsupported {
what: "ProjectionRegistration",
})
}
}
version
.stamp(
pool,
bounded_context_name,
skilj_core::db::RegistrationTable::Projections,
name,
)
.await?;
report.registered.push(key);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::warn_on_small_statement_cache;
#[test]
fn the_default_pool_skips_the_acquire_ping() {
let options = super::default_pool_options();
assert!(!options.get_test_before_acquire());
assert_eq!(
options.get_max_connections(),
super::PgPoolOptions::new().get_max_connections()
);
}
use skilj_core::bootstrap::ContextCreator;
use skilj_core::event_store::{BoundedContext, BoundedContextStatus};
fn contexts(active: usize, archived: usize) -> Vec<BoundedContext> {
(0..active + archived)
.map(|i| BoundedContext {
name: format!("bc{i}"),
status: if i < active {
BoundedContextStatus::Active
} else {
BoundedContextStatus::Archived
},
created_at: chrono::Utc::now(),
created_by: ContextCreator::SystemCreator,
template: None,
})
.collect()
}
#[test]
fn a_statement_cache_under_ten_per_active_bounded_context_warns() {
assert!(!warn_on_small_statement_cache(100, &contexts(10, 50)));
assert!(warn_on_small_statement_cache(100, &contexts(11, 0)));
assert!(!warn_on_small_statement_cache(400, &contexts(32, 0)));
assert!(warn_on_small_statement_cache(400, &contexts(64, 0)));
}
}