Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/epix-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ impl Database {
populate::populate(&conn, schema, db_dir.as_ref())
}

/// Populate, skipping data files whose path contains one of `exclude`
/// (ContentFilter mute enforcement - muted authors' files are left out).
pub fn populate_filtered(
&self,
schema: &DbSchema,
db_dir: impl AsRef<std::path::Path>,
exclude: &[String],
) -> Result<usize> {
let conn = self.conn()?;
populate::populate_site_filtered(&conn, schema, db_dir.as_ref(), "", exclude)
}

/// Populate a version-3 merger db from one merged site's files, tagging the
/// rows with `site`. Call once per merged site.
pub fn populate_site(
Expand Down
18 changes: 17 additions & 1 deletion crates/epix-db/src/populate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,25 @@ pub fn update_json(
/// that match a map. `db_dir` is the xite's content root; paths are matched
/// relative to it (forward slashes), like EpixNet.
pub fn populate(conn: &Connection, schema: &DbSchema, db_dir: &Path) -> Result<usize> {
populate_site(conn, schema, db_dir, "")
populate_site_filtered(conn, schema, db_dir, "", &[])
}

/// Like [`populate`], but tags every row with `site` - for a version-3 merger
/// db aggregating data from several merged sites (call once per merged site).
pub fn populate_site(conn: &Connection, schema: &DbSchema, db_dir: &Path, site: &str) -> Result<usize> {
populate_site_filtered(conn, schema, db_dir, site, &[])
}

/// Like [`populate_site`], but skips any data file whose path contains one of
/// `exclude` - the ContentFilter mute enforcement point (muted authors'
/// `data/<auth_address>/…` files are left out of the database).
pub fn populate_site_filtered(
conn: &Connection,
schema: &DbSchema,
db_dir: &Path,
site: &str,
exclude: &[String],
) -> Result<usize> {
let mut count = 0;
let mut stack = vec![db_dir.to_path_buf()];
while let Some(dir) = stack.pop() {
Expand All @@ -232,6 +245,9 @@ pub fn populate_site(conn: &Connection, schema: &DbSchema, db_dir: &Path, site:
}
let Ok(rel) = path.strip_prefix(db_dir) else { continue };
let rel_str = rel.to_string_lossy().replace('\\', "/");
if !exclude.is_empty() && exclude.iter().any(|e| rel_str.contains(e.as_str())) {
continue;
}
let Ok(bytes) = std::fs::read(&path) else { continue };
let Ok(data) = serde_json::from_slice::<Value>(&bytes) else { continue };
if update_json(conn, schema, &rel_str, &data, site)? {
Expand Down
14 changes: 10 additions & 4 deletions crates/epix-plugins/src/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,28 @@ impl WsCommand for SidebarGetPeers {
}
}

/// `siteSetSizeLimit` - set the per-xite size limit (MB).
/// `siteSetLimit` - set the per-xite size limit (MB). The sidebar's "Set" button
/// sends the input value positionally, as a string.
struct SiteSetSizeLimit;

#[async_trait]
impl WsCommand for SiteSetSizeLimit {
fn name(&self) -> &'static str {
"siteSetSizeLimit"
"siteSetLimit"
}

async fn handle(&self, s: &WsSession, p: &Value) -> Result<Value, String> {
let address = s.address()?.to_string();
let limit = p
let raw = p
.get("size_limit")
.or_else(|| p.as_array().and_then(|a| a.first()))
.and_then(|v| v.as_i64())
.or(Some(p))
.ok_or("size_limit required")?;
// Accept a number or a numeric string (the button posts a string).
let limit = raw
.as_i64()
.or_else(|| raw.as_str().and_then(|s| s.trim().parse::<i64>().ok()))
.ok_or("size_limit must be a number")?;
s.state.set_size_limit(&address, limit).await;
Ok(Value::String("ok".into()))
}
Expand Down
18 changes: 17 additions & 1 deletion crates/epix-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,24 @@ async fn connection_loop(state: Arc<AppState>, shutdown: Arc<Notify>, period: Du
/// data. Collects once immediately, then every `period`.
async fn chart_loop(state: Arc<AppState>, shutdown: Arc<Notify>, period: Duration) {
state.collect_chart().await;
// Enforce retention once at startup, then roughly once a day (the collector
// runs far more often, so archive only every Nth tick).
state.archive_chart().await;
let archive_every = (Duration::from_secs(24 * 60 * 60).as_secs() / period.as_secs().max(1)).max(1);
let mut ticks: u64 = 0;
let mut tick = interval(period);
tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
tick.tick().await; // consume the immediate first tick
loop {
tokio::select! {
_ = shutdown.notified() => break,
_ = tick.tick() => state.collect_chart().await,
_ = tick.tick() => {
state.collect_chart().await;
ticks += 1;
if ticks % archive_every == 0 {
state.archive_chart().await;
}
}
}
}
}
Expand All @@ -226,6 +237,11 @@ async fn resync_loop(state: Arc<AppState>, shutdown: Arc<Notify>, period: Durati
for address in state.xite_addresses().await {
let _ = state.resync_xite(&address).await;
}
// OptionalManager: keep optional files under the size cap.
let freed = state.enforce_optional_limit().await;
if freed > 0 {
state.log("INFO", format!("Optional-file cleanup freed {freed} bytes")).await;
}
}
}
}
Expand Down
45 changes: 45 additions & 0 deletions crates/epix-ui/src/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,23 @@ impl ChartDb {
}
}

/// Enforce retention so the chart db does not grow without bound: drop
/// per-site datapoints older than one month and global datapoints older than
/// six months, then reclaim the space. Mirrors EpixNet's `ChartDb.archive`
/// retention (without its downsampling of old rows).
pub fn archive(&self, now: i64) {
const MONTH: i64 = 60 * 60 * 24 * 30;
let _ = self.db.execute(
"DELETE FROM data WHERE site_id IS NOT NULL AND date_added < ?",
&[Value::from(now - MONTH)],
);
let _ = self.db.execute(
"DELETE FROM data WHERE site_id IS NULL AND date_added < ?",
&[Value::from(now - 6 * MONTH)],
);
let _ = self.db.execute_batch("VACUUM");
}

/// Run a read-only chart query (the `chartDbQuery` command). Only SELECT is
/// allowed, matching the Python action. `params` is bound by name (a
/// list-valued param expands `IN :key` into a placeholder list), so the
Expand All @@ -144,3 +161,31 @@ fn load_ids(db: &Database, sql: &str) -> HashMap<String, i64> {
}
out
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn archive_enforces_retention_windows() {
let db = ChartDb::memory().unwrap();
let now: i64 = 2_000_000_000;
let day = 60 * 60 * 24;
let site = db.site_id("1Site");
// Old global (7 months) + old per-site (2 months) should be dropped;
// recent points of each kind should stay.
db.record(now - day * 210, None, &[Metric::now("a", 1.0)]);
db.record(now - day * 60, site, &[Metric::now("a", 2.0)]);
db.record(now - day * 2, None, &[Metric::now("a", 3.0)]);
db.record(now - day * 2, site, &[Metric::now("a", 4.0)]);
assert_eq!(count(&db), 4);

db.archive(now);
assert_eq!(count(&db), 2);
}

fn count(db: &ChartDb) -> i64 {
db.query("SELECT COUNT(*) AS n FROM data", &json!({})).unwrap()[0]["n"].as_i64().unwrap()
}
}
Loading