308 lines
9.7 KiB
Rust
308 lines
9.7 KiB
Rust
use anyhow::anyhow;
|
|
use collab::entity::EncodedCollab;
|
|
use collab_entity::CollabType;
|
|
use redis::{pipe, AsyncCommands};
|
|
use tracing::{error, instrument, trace};
|
|
|
|
use crate::collab::decode_util::encode_collab_from_bytes;
|
|
use crate::state::RedisConnectionManager;
|
|
use app_error::AppError;
|
|
use database::collab::CollabMetadata;
|
|
|
|
const SEVEN_DAYS: u64 = 604800;
|
|
const ONE_MONTH: u64 = 2592000;
|
|
#[derive(Clone)]
|
|
pub struct CollabMemCache {
|
|
connection_manager: RedisConnectionManager,
|
|
}
|
|
|
|
impl CollabMemCache {
|
|
pub fn new(connection_manager: RedisConnectionManager) -> Self {
|
|
Self { connection_manager }
|
|
}
|
|
|
|
pub async fn insert_collab_meta(&self, meta: CollabMetadata) -> Result<(), AppError> {
|
|
let key = collab_meta_key(&meta.object_id);
|
|
let value = serde_json::to_string(&meta)?;
|
|
self
|
|
.connection_manager
|
|
.clone()
|
|
.set_ex(key, value, ONE_MONTH)
|
|
.await
|
|
.map_err(|err| {
|
|
AppError::Internal(anyhow!("Failed to save collab meta to redis: {:?}", err))
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_collab_meta(&self, object_id: &str) -> Result<CollabMetadata, AppError> {
|
|
let key = collab_meta_key(object_id);
|
|
let value: Option<String> = self
|
|
.connection_manager
|
|
.clone()
|
|
.get(key)
|
|
.await
|
|
.map_err(|err| {
|
|
AppError::Internal(anyhow!("Failed to get collab meta from redis: {:?}", err))
|
|
})?;
|
|
match value {
|
|
Some(value) => {
|
|
let meta: CollabMetadata = serde_json::from_str(&value)?;
|
|
Ok(meta)
|
|
},
|
|
None => Err(AppError::RecordNotFound(format!(
|
|
"Collab meta not found for object_id: {}",
|
|
object_id
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Checks if an object with the given ID exists in the cache.
|
|
pub async fn is_exist(&self, object_id: &str) -> Result<bool, AppError> {
|
|
let cache_object_id = encode_collab_key(object_id);
|
|
let exists: bool = self
|
|
.connection_manager
|
|
.clone()
|
|
.exists(&cache_object_id)
|
|
.await
|
|
.map_err(|err| AppError::Internal(err.into()))?;
|
|
Ok(exists)
|
|
}
|
|
|
|
pub async fn remove_encode_collab(&self, object_id: &str) -> Result<(), AppError> {
|
|
let cache_object_id = encode_collab_key(object_id);
|
|
self
|
|
.connection_manager
|
|
.clone()
|
|
.del::<&str, ()>(&cache_object_id)
|
|
.await
|
|
.map_err(|err| {
|
|
AppError::Internal(anyhow!(
|
|
"Failed to remove encoded collab from redis: {:?}",
|
|
err
|
|
))
|
|
})
|
|
}
|
|
|
|
pub async fn get_encode_collab_data(&self, object_id: &str) -> Option<Vec<u8>> {
|
|
match self.get_data_with_timestamp(object_id).await {
|
|
Ok(data) => data.map(|(_, bytes)| bytes),
|
|
Err(err) => {
|
|
error!("Failed to get encoded collab from redis: {:?}", err);
|
|
None
|
|
},
|
|
}
|
|
}
|
|
|
|
#[instrument(level = "trace", skip_all)]
|
|
pub async fn get_encode_collab(&self, object_id: &str) -> Option<EncodedCollab> {
|
|
match self.get_encode_collab_data(object_id).await {
|
|
Some(bytes) => encode_collab_from_bytes(bytes).await.ok(),
|
|
None => {
|
|
trace!(
|
|
"No encoded collab found in cache for object_id: {}",
|
|
object_id
|
|
);
|
|
None
|
|
},
|
|
}
|
|
}
|
|
|
|
#[instrument(level = "trace", skip_all, fields(object_id=%object_id))]
|
|
pub async fn insert_encode_collab(
|
|
&self,
|
|
object_id: &str,
|
|
encoded_collab: EncodedCollab,
|
|
timestamp: i64,
|
|
expiration_seconds: u64,
|
|
) {
|
|
trace!("Inserting encode collab into cache: {}", object_id);
|
|
let result = tokio::task::spawn_blocking(move || encoded_collab.encode_to_bytes()).await;
|
|
match result {
|
|
Ok(Ok(bytes)) => {
|
|
if let Err(err) = self
|
|
.insert_data_with_timestamp(object_id, &bytes, timestamp, Some(expiration_seconds))
|
|
.await
|
|
{
|
|
error!("Failed to cache encoded collab: {:?}", err);
|
|
}
|
|
},
|
|
Ok(Err(err)) => {
|
|
error!("Failed to encode collab to bytes: {:?}", err);
|
|
},
|
|
Err(e) => {
|
|
error!("Failed to encode collab to bytes: {:?}", e);
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Inserts data into Redis with a conditional timestamp.
|
|
/// if the expiration_seconds is None, the data will be expired after 7 days.
|
|
pub async fn insert_encode_collab_data(
|
|
&self,
|
|
object_id: &str,
|
|
data: &[u8],
|
|
timestamp: i64,
|
|
expiration_seconds: Option<u64>,
|
|
) -> redis::RedisResult<()> {
|
|
self
|
|
.insert_data_with_timestamp(object_id, data, timestamp, expiration_seconds)
|
|
.await
|
|
}
|
|
|
|
/// Inserts data into Redis with a conditional timestamp.
|
|
///
|
|
/// inserts data associated with an `object_id` into Redis only if the new timestamp is greater than the timestamp
|
|
/// currently stored in Redis for the same `object_id`. It uses Redis transactions to ensure that the operation is atomic.
|
|
/// the data will be expired after 7 days.
|
|
///
|
|
/// # Arguments
|
|
/// * `object_id` - A string identifier for the data object.
|
|
/// * `data` - The binary data to be stored.
|
|
/// * `timestamp` - A unix timestamp indicating the creation time of the data.
|
|
///
|
|
/// # Returns
|
|
/// A Redis result indicating the success or failure of the operation.
|
|
async fn insert_data_with_timestamp(
|
|
&self,
|
|
object_id: &str,
|
|
data: &[u8],
|
|
timestamp: i64,
|
|
expiration_seconds: Option<u64>,
|
|
) -> redis::RedisResult<()> {
|
|
let cache_object_id = encode_collab_key(object_id);
|
|
let mut conn = self.connection_manager.clone();
|
|
let key_exists: bool = conn.exists(&cache_object_id).await?;
|
|
// Start a watch on the object_id to monitor for changes during this transaction
|
|
if key_exists {
|
|
// WATCH command is used to monitor one or more keys for modifications, establishing a condition
|
|
// for executing a subsequent transaction (with MULTI/EXEC). If any of the watched keys are
|
|
// altered by another client before the current client executes EXEC, the transaction will be
|
|
// aborted by Redis (the EXEC will return nil indicating the transaction was not processed).
|
|
redis::cmd("WATCH")
|
|
.arg(&cache_object_id)
|
|
.query_async::<_, ()>(&mut conn)
|
|
.await?;
|
|
}
|
|
|
|
let result = async {
|
|
// Retrieve the current data, if exists
|
|
let current_value: Option<(i64, Vec<u8>)> = if key_exists {
|
|
let val: Option<Vec<u8>> = conn.get(&cache_object_id).await?;
|
|
val.and_then(|data| {
|
|
if data.len() < 8 {
|
|
None
|
|
} else {
|
|
match data[0..8].try_into() {
|
|
Ok(ts_bytes) => {
|
|
let ts = i64::from_be_bytes(ts_bytes);
|
|
Some((ts, data[8..].to_vec()))
|
|
},
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Perform update only if the new timestamp is greater than the existing one
|
|
if current_value
|
|
.as_ref()
|
|
.map_or(true, |(ts, _)| timestamp >= *ts)
|
|
{
|
|
let mut pipeline = pipe();
|
|
let data = [timestamp.to_be_bytes().as_ref(), data].concat();
|
|
pipeline
|
|
.atomic()
|
|
.set(&cache_object_id, data)
|
|
.ignore()
|
|
.expire(&cache_object_id, expiration_seconds.unwrap_or(SEVEN_DAYS) as i64) // Setting the expiration to 7 days
|
|
.ignore();
|
|
pipeline.query_async(&mut conn).await?;
|
|
}
|
|
Ok::<(), redis::RedisError>(())
|
|
}
|
|
.await;
|
|
|
|
// Always reset Watch State
|
|
redis::cmd("UNWATCH")
|
|
.query_async::<_, ()>(&mut conn)
|
|
.await?;
|
|
|
|
result
|
|
}
|
|
|
|
/// Retrieves data and its associated timestamp from Redis for a given object identifier.
|
|
///
|
|
/// # Arguments
|
|
/// * `object_id` - A unique identifier for the data.
|
|
///
|
|
/// # Returns
|
|
/// A `RedisResult<Option<(i64, Vec<u8>)>>` where:
|
|
/// - `i64` is the timestamp of the data.
|
|
/// - `Vec<u8>` is the binary data.
|
|
///
|
|
/// The function returns `Ok(None)` if no data is found for the given `object_id`.
|
|
async fn get_data_with_timestamp(
|
|
&self,
|
|
object_id: &str,
|
|
) -> redis::RedisResult<Option<(i64, Vec<u8>)>> {
|
|
let cache_object_id = encode_collab_key(object_id);
|
|
let mut conn = self.connection_manager.clone();
|
|
// Attempt to retrieve the data from Redis
|
|
if let Some(data) = conn.get::<_, Option<Vec<u8>>>(&cache_object_id).await? {
|
|
if data.len() < 8 {
|
|
// Data is too short to contain a valid timestamp and payload
|
|
Err(redis::RedisError::from((
|
|
redis::ErrorKind::TypeError,
|
|
"Data corruption: stored data is too short to contain a valid timestamp.",
|
|
)))
|
|
} else {
|
|
// Extract timestamp and payload from the retrieved data
|
|
match data[0..8].try_into() {
|
|
Ok(ts_bytes) => {
|
|
let timestamp = i64::from_be_bytes(ts_bytes);
|
|
let payload = data[8..].to_vec();
|
|
Ok(Some((timestamp, payload)))
|
|
},
|
|
Err(_) => Err(redis::RedisError::from((
|
|
redis::ErrorKind::TypeError,
|
|
"Failed to decode timestamp",
|
|
))),
|
|
}
|
|
}
|
|
} else {
|
|
// No data found for the provided object_id
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generates a cache-specific key for an object ID by prepending a fixed prefix.
|
|
/// This method ensures that any updates to the object's data involve merely
|
|
/// changing the prefix, allowing the old data to expire naturally.
|
|
///
|
|
#[inline]
|
|
fn encode_collab_key(object_id: &str) -> String {
|
|
format!("encode_collab_v0:{}", object_id)
|
|
}
|
|
|
|
#[inline]
|
|
fn collab_meta_key(object_id: &str) -> String {
|
|
format!("collab_meta_v0:{}", object_id)
|
|
}
|
|
|
|
#[inline]
|
|
pub fn cache_exp_secs_from_collab_type(collab_type: &CollabType) -> u64 {
|
|
match collab_type {
|
|
CollabType::Document => SEVEN_DAYS * 2,
|
|
CollabType::Database => SEVEN_DAYS * 2,
|
|
CollabType::WorkspaceDatabase => ONE_MONTH,
|
|
CollabType::Folder => SEVEN_DAYS,
|
|
CollabType::DatabaseRow => ONE_MONTH,
|
|
CollabType::UserAwareness => SEVEN_DAYS * 2,
|
|
CollabType::Unknown => SEVEN_DAYS,
|
|
}
|
|
}
|