use crate::errors::{FirestoreCacheError, FirestoreErrorPublicGenericDetails};
use crate::*;
use std::collections::HashMap;
use std::sync::Arc;
pub(crate) fn cache_incomplete_collection_error(
collection_id: &str,
reason: &str,
) -> FirestoreError {
FirestoreError::CacheError(FirestoreCacheError::new(
FirestoreErrorPublicGenericDetails::new("CacheIncompleteCollection".into()),
format!(
"The cache cannot serve this request for collection `{collection_id}` completely: \
{reason}. Reading it from the cache would silently return partial results. \
Either configure the collection with FirestoreCacheCollectionLoadMode::PreloadAllDocs \
(or PreloadAllIfEmpty), or use `db.read_through_cache(&cache)` to fall back to \
Firestore. To opt back into the previous partial-result behaviour, set \
FirestoreCacheIncompleteCollectionPolicy::PartialResults on the cache configuration."
),
))
}
mod options;
pub use options::*;
mod configuration;
pub use configuration::*;
mod builder;
pub use builder::*;
mod backends;
pub use backends::*;
use async_trait::async_trait;
use futures::stream::BoxStream;
use futures::StreamExt;
use tracing::*;
mod cache_filter_engine;
mod cache_query_engine;
pub struct FirestoreCache<B, LS>
where
B: FirestoreCacheBackend + Send + Sync + 'static,
LS: FirestoreResumeStateStorage,
{
inner: FirestoreCacheInner<B, LS>,
}
struct FirestoreCacheInner<B, LS>
where
B: FirestoreCacheBackend + Send + Sync + 'static,
LS: FirestoreResumeStateStorage,
{
pub options: FirestoreCacheOptions,
pub backend: Arc<B>,
pub listener: tokio::sync::Mutex<FirestoreListener<FirestoreDb, LS>>,
pub db: FirestoreDb,
pub collection_mutations: tokio::sync::Mutex<u32>,
}
#[cfg(feature = "caching-memory")]
pub type FirestoreMemoryCache =
FirestoreCache<FirestoreMemoryCacheBackend, FirestoreMemListenStateStorage>;
#[cfg(feature = "caching-persistent")]
pub type FirestorePersistentCache =
FirestoreCache<FirestorePersistentCacheBackend, FirestoreTempFilesListenStateStorage>;
#[cfg(feature = "caching-memory")]
impl FirestoreMemoryCache {
#[inline]
pub fn memory(db: &FirestoreDb) -> FirestoreCacheBuilder<FirestoreMemoryCacheKind> {
FirestoreCacheBuilder::new(db)
}
#[inline]
pub fn builder(db: &FirestoreDb) -> FirestoreCacheBuilder<FirestoreMemoryCacheKind> {
FirestoreCacheBuilder::new(db)
}
}
#[cfg(feature = "caching-persistent")]
impl FirestorePersistentCache {
#[inline]
pub fn persistent(db: &FirestoreDb) -> FirestoreCacheBuilder<FirestorePersistentCacheKind> {
FirestoreCacheBuilder::new(db)
}
#[inline]
pub fn builder(db: &FirestoreDb) -> FirestoreCacheBuilder<FirestorePersistentCacheKind> {
FirestoreCacheBuilder::new(db)
}
}
pub enum FirestoreCachedValue<T> {
UseCached(T),
SkipCache,
}
impl<B, LS> FirestoreCache<B, LS>
where
B: FirestoreCacheBackend + Send + Sync + 'static,
LS: FirestoreResumeStateStorage + Send + Sync + 'static,
{
#[deprecated(
since = "0.52.0",
note = "Use the cache builder instead: `FirestoreCache::memory(&db)` or \
`FirestoreCache::persistent(&db)`. It assigns listener targets automatically, \
picks a matching listener state storage and loads the cache for you. \
This constructor keeps working and will not be removed in 0.x."
)]
pub async fn new(
name: FirestoreCacheName,
db: &FirestoreDb,
backend: B,
listener_storage: LS,
) -> FirestoreResult<Self>
where
B: FirestoreCacheBackend + Send + Sync + 'static,
{
let options = FirestoreCacheOptions::new(name);
Self::create(options, db, backend, listener_storage).await
}
#[deprecated(
since = "0.52.0",
note = "Use the cache builder instead: `FirestoreCache::memory(&db)` or \
`FirestoreCache::persistent(&db)`. It assigns listener targets automatically, \
picks a matching listener state storage and loads the cache for you. \
This constructor keeps working and will not be removed in 0.x."
)]
pub async fn with_options(
options: FirestoreCacheOptions,
db: &FirestoreDb,
backend: B,
listener_storage: LS,
) -> FirestoreResult<Self>
where
B: FirestoreCacheBackend + Send + Sync + 'static,
{
Self::create(options, db, backend, listener_storage).await
}
pub(crate) async fn create(
options: FirestoreCacheOptions,
db: &FirestoreDb,
backend: B,
listener_storage: LS,
) -> FirestoreResult<Self>
where
B: FirestoreCacheBackend + Send + Sync + 'static,
{
let listener = if let Some(ref listener_params) = options.listener_params {
db.create_listener_with_params(listener_storage, listener_params.clone())
.await?
} else {
db.create_listener(listener_storage).await?
};
let next_listener_target = backend
.cache_configuration()
.max_listener_target()
.map(|max| max.saturating_add(1))
.unwrap_or(FIRESTORE_CACHE_DEFAULT_LISTENER_TARGET_BASE);
Ok(Self {
inner: FirestoreCacheInner {
options,
backend: Arc::new(backend),
listener: tokio::sync::Mutex::new(listener),
db: db.clone(),
collection_mutations: tokio::sync::Mutex::new(next_listener_target),
},
})
}
pub fn name(&self) -> &FirestoreCacheName {
&self.inner.options.name
}
pub async fn load(&self) -> Result<(), FirestoreError> {
let backend_target_params = self
.inner
.backend
.load(&self.inner.options, &self.inner.db)
.await?;
let mut listener = self.inner.listener.lock().await;
for target_params in backend_target_params {
listener.add_target(target_params)?;
}
let handler = Arc::new(FirestoreCacheListenHandler::new(
self.inner.backend.clone(),
listener.control_handle(),
));
listener
.start(move |event| {
let handler = handler.clone();
async move {
handler.on_listen_event(event).await;
Ok(())
}
})
.await?;
Ok(())
}
pub async fn shutdown(&self) -> Result<(), FirestoreError> {
let listener_stopped_with = self.inner.listener.lock().await.shutdown().await;
self.inner.backend.shutdown().await?;
listener_stopped_with
}
pub async fn add_collection(
&self,
collection: FirestoreCacheCollection,
) -> FirestoreResult<()> {
let mut next_listener_target = self.inner.collection_mutations.lock().await;
let documents_path = self.inner.db.get_documents_path();
let config = self.inner.backend.cache_configuration();
let listener_target = match collection.requested_listener_target() {
Some(target) => {
let target = FirestoreListenerTarget::new(target);
target.validate()?;
target
}
None => {
let allocated = config.allocate_listener_target(*next_listener_target)?;
*next_listener_target = allocated.value().saturating_add(1);
allocated
}
};
let collection_config = collection.into_configuration(listener_target);
let collection_path = collection_config.resolve_collection_path(documents_path);
if config.collections.contains_key(&collection_path) {
return Err(FirestoreError::CacheError(FirestoreCacheError::new(
FirestoreErrorPublicGenericDetails::new("CacheCollectionAlreadyCached".into()),
format!("The collection `{collection_path}` is already cached."),
)));
}
let target_params = self
.inner
.backend
.add_collection(&self.inner.options, &self.inner.db, collection_config)
.await?;
self.inner.listener.lock().await.add_target(target_params)?;
info!(collection_path, "Added a collection to the cache.");
Ok(())
}
pub async fn remove_collection<S>(&self, collection_name: S) -> FirestoreResult<bool>
where
S: AsRef<str>,
{
let collection_path = format!(
"{}/{}",
self.inner.db.get_documents_path(),
collection_name.as_ref()
);
self.remove_collection_at(&collection_path).await
}
pub async fn remove_collection_at(&self, collection_path: &str) -> FirestoreResult<bool> {
let Some(target) = self
.inner
.backend
.remove_collection(collection_path)
.await?
else {
return Ok(false);
};
self.inner
.listener
.lock()
.await
.remove_target(&target)
.await?;
info!(collection_path, "Removed a collection from the cache.");
Ok(true)
}
pub fn cached_collections(&self) -> Vec<String> {
self.inner
.backend
.cache_configuration()
.collections
.keys()
.cloned()
.collect()
}
pub fn backend(&self) -> Arc<B> {
self.inner.backend.clone()
}
pub async fn invalidate_all(&self) -> FirestoreResult<()> {
self.inner.backend.invalidate_all().await
}
}
#[async_trait]
pub trait FirestoreCacheBackend: FirestoreCacheDocsByPathSupport {
async fn load(
&self,
options: &FirestoreCacheOptions,
db: &FirestoreDb,
) -> Result<Vec<FirestoreListenerTargetParams>, FirestoreError>;
async fn invalidate_all(&self) -> FirestoreResult<()>;
async fn shutdown(&self) -> FirestoreResult<()>;
async fn on_listen_event(&self, event: FirestoreListenEvent) -> FirestoreResult<()>;
fn cache_configuration(&self) -> Arc<FirestoreCacheConfiguration> {
Arc::new(FirestoreCacheConfiguration::new())
}
async fn add_collection(
&self,
options: &FirestoreCacheOptions,
db: &FirestoreDb,
collection_config: FirestoreCacheCollectionConfiguration,
) -> FirestoreResult<FirestoreListenerTargetParams> {
let _ = (options, db, collection_config);
Err(cache_dynamic_collections_unsupported_error(
"add_collection",
))
}
async fn begin_collection_resync(&self, collection_path: &str) -> FirestoreResult<()> {
let _ = collection_path;
self.invalidate_all().await
}
async fn authoritative_doc_count(&self, collection_path: &str) -> FirestoreResult<Option<u64>> {
let _ = collection_path;
Ok(None)
}
async fn remove_collection(
&self,
collection_path: &str,
) -> FirestoreResult<Option<FirestoreListenerTarget>> {
let _ = collection_path;
Err(cache_dynamic_collections_unsupported_error(
"remove_collection",
))
}
}
const FIRESTORE_CACHE_MIN_RESYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
struct FirestoreCacheListenHandler<B, LS> {
backend: Arc<B>,
listener: FirestoreListenerControlHandle<LS>,
state: tokio::sync::Mutex<FirestoreCacheListenState>,
}
#[derive(Default)]
struct FirestoreCacheListenState {
pending_filters: HashMap<FirestoreListenerTarget, i32>,
last_resync: HashMap<FirestoreListenerTarget, std::time::Instant>,
}
impl<B, LS> FirestoreCacheListenHandler<B, LS>
where
B: FirestoreCacheBackend + Send + Sync + 'static,
LS: FirestoreResumeStateStorage,
{
fn new(backend: Arc<B>, listener: FirestoreListenerControlHandle<LS>) -> Self {
Self {
backend,
listener,
state: tokio::sync::Mutex::new(FirestoreCacheListenState::default()),
}
}
async fn on_listen_event(&self, event: FirestoreListenEvent) {
match &event {
FirestoreListenEvent::Filter(filter) => {
if let Ok(target) = FirestoreListenerTarget::try_from(filter.target_id) {
if filter.unchanged_names.is_some() {
debug!(
?target,
"Firestore sent a bloom filter with its existence filter; this cache resynchronises on a count mismatch instead of using it.",
);
}
self.state
.lock()
.await
.pending_filters
.insert(target, filter.count);
}
return;
}
FirestoreListenEvent::TargetChange(target_change) => {
if matches!(
FirestoreListenerTargetChangeType::try_from(target_change.target_change_type),
Ok(FirestoreListenerTargetChangeType::Reset)
| Ok(FirestoreListenerTargetChangeType::Remove)
) {
self.discard_pending_filters(&target_change.target_ids)
.await;
}
let settles_snapshot = matches!(
FirestoreListenerTargetChangeType::try_from(target_change.target_change_type),
Ok(FirestoreListenerTargetChangeType::NoChange)
| Ok(FirestoreListenerTargetChangeType::Current)
);
if settles_snapshot && target_change.read_time.is_some() {
self.settle_pending_filters(&target_change.target_ids).await;
}
}
_ => {}
}
if let Err(err) = self.backend.on_listen_event(event).await {
error!(?err, "Error occurred while updating cache.");
}
}
async fn discard_pending_filters(&self, target_ids: &[i32]) {
let mut state = self.state.lock().await;
if target_ids.is_empty() {
state.pending_filters.clear();
return;
}
for target in target_ids
.iter()
.filter_map(|id| FirestoreListenerTarget::try_from(*id).ok())
{
state.pending_filters.remove(&target);
}
}
async fn settle_pending_filters(&self, target_ids: &[i32]) {
let settled: Vec<(FirestoreListenerTarget, i32)> = {
let mut state = self.state.lock().await;
if state.pending_filters.is_empty() {
return;
}
if target_ids.is_empty() {
state.pending_filters.drain().collect()
} else {
target_ids
.iter()
.filter_map(|id| FirestoreListenerTarget::try_from(*id).ok())
.filter_map(|target| {
state
.pending_filters
.remove(&target)
.map(|count| (target, count))
})
.collect()
}
};
let config = self.backend.cache_configuration();
for (target, remote_count) in settled {
let Some(collection_path) = config
.collections
.iter()
.find(|(_, c)| c.listener_target == target)
.map(|(path, _)| path.clone())
else {
continue;
};
let local_count = match self.backend.authoritative_doc_count(&collection_path).await {
Ok(Some(count)) => count,
Ok(None) => {
debug!(
collection_path,
"Cannot compare this collection's document count with Firestore's, so the existence filter is ignored.",
);
continue;
}
Err(err) => {
warn!(?err, collection_path, "Could not count cached documents.");
continue;
}
};
if i64::from(remote_count) == local_count as i64 {
trace!(
collection_path,
local_count,
"The cache agrees with Firestore."
);
continue;
}
self.resync(target, &collection_path, local_count, remote_count)
.await;
}
}
async fn resync(
&self,
target: FirestoreListenerTarget,
collection_path: &str,
local_count: u64,
remote_count: i32,
) {
{
let mut state = self.state.lock().await;
if let Some(last) = state.last_resync.get(&target) {
if last.elapsed() < FIRESTORE_CACHE_MIN_RESYNC_INTERVAL {
warn!(
collection_path,
local_count,
remote_count,
"The cache still disagrees with Firestore, but it was resynchronised recently. Skipping this one.",
);
return;
}
}
state
.last_resync
.insert(target.clone(), std::time::Instant::now());
}
warn!(
collection_path,
local_count,
remote_count,
"The cache holds a different number of documents than Firestore reports, so it has missed changes. Resynchronising the collection.",
);
if let Err(err) = self.backend.begin_collection_resync(collection_path).await {
error!(
?err,
collection_path,
"Could not drop a diverged collection, so it was not resynchronised."
);
return;
}
self.listener.resync_target(target);
}
}
const FIRESTORE_CACHE_READ_TIME_SKEW_MARGIN: std::time::Duration =
std::time::Duration::from_secs(5);
pub(crate) fn cache_target_read_time() -> FirestoreInstant {
let now = FirestoreInstant::now();
jiff::SignedDuration::try_from(FIRESTORE_CACHE_READ_TIME_SKEW_MARGIN)
.ok()
.and_then(|margin| now.checked_sub(margin).ok())
.unwrap_or(now)
}
pub(crate) fn target_params_for_collection(
collection_config: &FirestoreCacheCollectionConfiguration,
resume_type: Option<FirestoreListenerTargetResumeType>,
) -> FirestoreListenerTargetParams {
let target_type = match collection_config.watched_document_ids() {
Some(document_ids) => FirestoreTargetType::Documents(
FirestoreCollectionDocuments::new(
collection_config.collection_name.clone(),
document_ids.to_vec(),
)
.opt_parent(collection_config.parent.clone()),
),
None => FirestoreTargetType::Query(
FirestoreQueryParams::new(collection_config.collection_name.as_str().into())
.opt_parent(collection_config.parent.clone()),
),
};
FirestoreListenerTargetParams::new(
collection_config.listener_target.clone(),
target_type,
std::collections::HashMap::new(),
)
.opt_resume_type(resume_type)
}
pub(crate) fn cache_dynamic_collections_unsupported_error(operation: &str) -> FirestoreError {
FirestoreError::CacheError(FirestoreCacheError::new(
FirestoreErrorPublicGenericDetails::new("CacheDynamicCollectionsUnsupported".into()),
format!(
"This cache backend does not support `{operation}`. Implement it on your \
FirestoreCacheBackend to add and remove cached collections while the cache runs."
),
))
}
pub enum FirestoreCacheTargetChangeAction {
SuspendAndInvalidate(Vec<FirestoreCacheInvalidation>),
Resume(Vec<String>),
Ignore,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum FirestoreCacheInvalidation {
Collection(String),
Documents {
collection_path: String,
document_ids: Vec<String>,
},
}
impl FirestoreCacheInvalidation {
pub fn collection_path(&self) -> &str {
match self {
Self::Collection(collection_path) => collection_path,
Self::Documents {
collection_path, ..
} => collection_path,
}
}
}
pub(crate) fn cache_target_change_action(
config: &FirestoreCacheConfiguration,
target_change: &gcloud_sdk::google::firestore::v1::TargetChange,
) -> FirestoreCacheTargetChangeAction {
use gcloud_sdk::google::firestore::v1::target_change::TargetChangeType;
let Ok(change_type) = TargetChangeType::try_from(target_change.target_change_type) else {
return FirestoreCacheTargetChangeAction::Ignore;
};
match change_type {
TargetChangeType::Reset | TargetChangeType::Remove => {
FirestoreCacheTargetChangeAction::SuspendAndInvalidate(
affected_scopes(config, &target_change.target_ids)
.into_iter()
.map(|scope| match scope {
FirestoreCacheTargetScope::Collection(collection_path) => {
FirestoreCacheInvalidation::Collection(collection_path.to_string())
}
FirestoreCacheTargetScope::Documents {
collection_path,
document_ids,
} => FirestoreCacheInvalidation::Documents {
collection_path: collection_path.to_string(),
document_ids: document_ids.to_vec(),
},
})
.collect(),
)
}
TargetChangeType::Current => FirestoreCacheTargetChangeAction::Resume(
affected_scopes(config, &target_change.target_ids)
.into_iter()
.map(|scope| scope.collection_path().to_string())
.collect(),
),
TargetChangeType::NoChange | TargetChangeType::Add => {
FirestoreCacheTargetChangeAction::Ignore
}
}
}
fn affected_scopes<'a>(
config: &'a FirestoreCacheConfiguration,
target_ids: &[i32],
) -> Vec<FirestoreCacheTargetScope<'a>> {
if target_ids.is_empty() {
return config.all_target_scopes();
}
target_ids
.iter()
.filter_map(|target_id_num| FirestoreListenerTarget::try_from(*target_id_num).ok())
.filter_map(|target| config.target_scope(&target))
.collect()
}
#[async_trait]
pub trait FirestoreCacheDocsByPathSupport {
async fn get_doc_by_path(
&self,
document_path: &str,
) -> FirestoreResult<Option<FirestoreDocument>>;
async fn get_docs_by_paths<'a>(
&'a self,
full_doc_ids: &'a [String],
) -> FirestoreResult<BoxStream<'a, FirestoreResult<(String, Option<FirestoreDocument>)>>>
where
Self: Sync,
{
Ok(Box::pin(futures::stream::iter(full_doc_ids).filter_map({
move |document_path| async move {
match self.get_doc_by_path(document_path.as_str()).await {
Ok(maybe_doc) => maybe_doc.map(|document| {
let doc_id = document
.name
.split('/')
.next_back()
.map(|s| s.to_string())
.unwrap_or_else(|| document.name.clone());
Ok((doc_id, Some(document)))
}),
Err(err) => {
error!(%err, "Error occurred while reading from cache.");
None
}
}
}
})))
}
async fn update_doc_by_path(&self, document: &FirestoreDocument) -> FirestoreResult<()>;
async fn list_all_docs<'b>(
&self,
collection_path: &str,
) -> FirestoreResult<FirestoreCachedValue<BoxStream<'b, FirestoreResult<FirestoreDocument>>>>;
async fn query_docs<'b>(
&self,
collection_path: &str,
query: &FirestoreQueryParams,
) -> FirestoreResult<FirestoreCachedValue<BoxStream<'b, FirestoreResult<FirestoreDocument>>>>;
}
#[cfg(test)]
mod target_change_tests {
use super::*;
use gcloud_sdk::google::firestore::v1::TargetChange;
const DOCS: &str = "projects/test/databases/(default)/documents";
fn config_with(collections: &[(&str, u32)]) -> FirestoreCacheConfiguration {
collections.iter().fold(
FirestoreCacheConfiguration::new(),
|config, (name, target)| {
config.add_collection_config_at(
DOCS,
FirestoreCacheCollectionConfiguration::new(
name,
FirestoreListenerTarget::new(*target),
FirestoreCacheCollectionLoadMode::PreloadAllDocs,
),
)
},
)
}
fn change(
change_type: FirestoreListenerTargetChangeType,
target_ids: Vec<i32>,
) -> TargetChange {
TargetChange {
target_change_type: change_type as i32,
target_ids,
..Default::default()
}
}
fn suspended(action: FirestoreCacheTargetChangeAction) -> Vec<String> {
match action {
FirestoreCacheTargetChangeAction::SuspendAndInvalidate(invalidations) => sorted(
invalidations
.into_iter()
.map(|invalidation| invalidation.collection_path().to_string())
.collect(),
),
other => panic!(
"expected SuspendAndInvalidate, got {}",
describe_action(&other)
),
}
}
fn resumed(action: FirestoreCacheTargetChangeAction) -> Vec<String> {
match action {
FirestoreCacheTargetChangeAction::Resume(paths) => sorted(paths),
other => panic!("expected Resume, got {}", describe_action(&other)),
}
}
fn sorted(mut paths: Vec<String>) -> Vec<String> {
paths.sort();
paths
}
fn describe_action(action: &FirestoreCacheTargetChangeAction) -> &'static str {
match action {
FirestoreCacheTargetChangeAction::SuspendAndInvalidate(_) => "SuspendAndInvalidate",
FirestoreCacheTargetChangeAction::Resume(_) => "Resume",
FirestoreCacheTargetChangeAction::Ignore => "Ignore",
}
}
#[test]
fn reset_suspends_only_the_collections_of_the_named_targets() {
let config = config_with(&[("a", 1000), ("b", 1001)]);
let action = cache_target_change_action(
&config,
&change(FirestoreListenerTargetChangeType::Reset, vec![1000]),
);
assert_eq!(suspended(action), vec![format!("{DOCS}/a")]);
}
#[test]
fn remove_suspends_like_reset_does() {
let config = config_with(&[("a", 1000), ("b", 1001)]);
let action = cache_target_change_action(
&config,
&change(FirestoreListenerTargetChangeType::Remove, vec![1001]),
);
assert_eq!(suspended(action), vec![format!("{DOCS}/b")]);
}
#[test]
fn an_empty_target_id_set_means_every_target() {
let config = config_with(&[("a", 1000), ("b", 1001)]);
let action = cache_target_change_action(
&config,
&change(FirestoreListenerTargetChangeType::Reset, vec![]),
);
assert_eq!(
suspended(action),
vec![format!("{DOCS}/a"), format!("{DOCS}/b")]
);
}
#[test]
fn current_resumes_the_collection() {
let config = config_with(&[("a", 1000)]);
let action = cache_target_change_action(
&config,
&change(FirestoreListenerTargetChangeType::Current, vec![1000]),
);
assert_eq!(resumed(action), vec![format!("{DOCS}/a")]);
}
#[test]
fn keepalives_and_adds_do_nothing() {
let config = config_with(&[("a", 1000)]);
for change_type in [
FirestoreListenerTargetChangeType::NoChange,
FirestoreListenerTargetChangeType::Add,
] {
assert!(matches!(
cache_target_change_action(&config, &change(change_type, vec![1000])),
FirestoreCacheTargetChangeAction::Ignore
));
}
}
#[test]
fn targets_belonging_to_another_listener_are_ignored() {
let config = config_with(&[("a", 1000)]);
let action = cache_target_change_action(
&config,
&change(FirestoreListenerTargetChangeType::Reset, vec![42]),
);
assert!(suspended(action).is_empty());
}
}