# How ai-memory Implements MCP Client Activity Tracking via Bounded UTC-Day Buckets

> Discover how ai-memory tracks MCP client activity using bounded UTC-day buckets. Learn about buffering, background flushing, and memory-bounded overflow handling.

- Repository: [Fabio Akita/ai-memory](https://github.com/akitaonrails/ai-memory)
- Tags: deep-dive
- Published: 2026-08-30

---

**ai-memory tracks MCP tool call activity by buffering read/write counts into per-client, per-day buckets keyed by UTC day numbers, with automatic background flushing to SQLite and memory-bounded overflow handling.**

The `akitaonrails/ai-memory` repository implements a lightweight yet robust activity tracking system for MCP (Model Context Protocol) clients. Rather than logging every individual request, the system aggregates metrics into **bounded UTC-day buckets**—providing daily granularity without unbounded memory growth. This article examines the complete implementation from buffer structure to persistent storage.

---

## UTC-Day Bucket Foundation: US_PER_DAY

All time-based bucketing rests on a single foundational constant defined in the MCP server crate:

```rust
// crates/ai-memory-mcp/src/server.rs
const US_PER_DAY: i64 = 86_400_000_000; // microseconds in one UTC day

```

This constant converts any microsecond-precision timestamp into a **day number** via simple integer division. Using UTC eliminates timezone skew, ensuring that activity counts align to calendar days regardless of where clients or servers run.

```rust
let now_us = chrono::Utc::now().timestamp_micros();
let utc_day = now_us / US_PER_DAY; // bucket identifier: days since Unix epoch

```

---

## ClientActivityBuffer: Bounded In-Memory Aggregation

The `ClientActivityBuffer` struct in [`crates/ai-memory-mcp/src/server.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-mcp/src/server.rs) serves as the hot path for all activity recording:

```rust
pub struct ClientActivityBuffer {
    inner: Mutex<HashMap<(String, i64), (u32, u32)>>,
    // (client_name, utc_day) -> (read_count, write_count)
}

```

Key design decisions ensure bounded memory:

- **Per-day, per-client keys** prevent cross-day pollution
- **u32 counters** provide sufficient headroom while staying compact
- **Overflow handling** caps distinct clients per day

### Recording Activity with record and record_delta

Each MCP tool call triggers `buffer.record()`, which delegates to `record_delta()` for actual map manipulation:

```rust
// Simplified conceptual flow from crates/ai-memory-mcp/src/server.rs
impl ClientActivityBuffer {
    pub fn record(&self, client: String, day: i64, is_write: bool) -> bool {
        let mut map = self.inner.lock().unwrap();
        self.record_delta(&mut map, client, day, is_write)
    }
    
    fn record_delta(
        &self,
        map: &mut HashMap<(String, i64), (u32, u32)>,
        client: String,
        day: i64,
        is_write: bool
    ) -> bool {
        // Returns true if flusher should be started (first entry)
        // Handles CLIENT_ACTIVITY_OVERFLOW_CLIENT when limits exceeded
    }
}

```

The boolean return signals when the **first entry** enters an empty buffer—triggering the background flush loop initialization.

---

## Memory Bounding via Client Limits

To prevent unbounded growth from malicious or misbehaving clients, the buffer enforces `CLIENT_ACTIVITY_MAX_CLIENTS_PER_DAY`. When exceeded, subsequent distinct client names collapse into a reserved overflow bucket:

```rust
const CLIENT_ACTIVITY_OVERFLOW_CLIENT: &str = "__overflow__";
// crates/ai-memory-mcp/src/server.rs lines 70-78

```

This guarantees that memory consumption scales with **configured limits**, not with arbitrary client cardinality. The overflow bucket preserves aggregate accuracy while sacrificing per-client granularity only for excess traffic.

---

## Background Flush Loop: Periodic Persistence

A dedicated async task moves buffered data to durable storage without blocking request handlers:

```rust
// crates/ai-memory-mcp/src/server.rs lines 98-128
async fn flush_client_activity_loop(
    buffer: Arc<ClientActivityBuffer>,
    writer: WriterHandle,
    interval: Duration,
) {
    let mut ticker = tokio::time::interval(interval);
    loop {
        ticker.tick().await;
        
        let entries = buffer.take_entries(); // atomically extract
        if entries.is_empty() { continue; }
        
        match writer.bump_client_activity(entries).await {
            Ok(_) => {},
            Err(e) => {
                // Restore entries for retry; log and continue
                buffer.restore(entries);
            }
        }
    }
}

```

Key characteristics:

- **60-second default interval** (`CLIENT_ACTIVITY_FLUSH`) balances latency and I/O
- **Atomic `take_entries()`** swaps in an empty map, minimizing lock contention
- **Failure recovery** restores unwritten batches without data loss

---

## WriterHandle::bump_client_activity to SQLite

The flush loop delegates to `WriterHandle`, defined in [`crates/ai-memory-store/src/writer.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/writer.rs):

```rust
// crates/ai-memory-store/src/writer.rs lines 1051-1059
pub async fn bump_client_activity(
    &self,
    entries: Vec<((String, i64), (u32, u32))>,
) -> Result<(), StoreError> {
    let op = StoreOp::BumpClientActivity { entries };
    self.send(op).await
}

```

The actual SQL implementation resides in [`crates/ai-memory-store/src/ops.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/ops.rs) (around lines 1494-1506), performing an **UPSERT** against the `client_activity` table:

```sql
INSERT INTO client_activity (client, utc_day, reads, writes)
VALUES (?, ?, ?, ?)
ON CONFLICT(client, utc_day) DO UPDATE SET
    reads = reads + excluded.reads,
    writes = writes + excluded.writes;

```

This atomic accumulation ensures that concurrent flushes—or retries after failures—produce correct final counts.

---

## Reading Aggregated Activity

The read path exposes daily-bucketed statistics via `Store::client_activity` in [`crates/ai-memory-store/src/reader.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/reader.rs):

```rust
// crates/ai-memory-store/src/reader.rs lines 92-106
pub struct ClientActivity {
    pub client: String,
    pub reads: u64,
    pub writes: u64,
}

impl Store {
    pub async fn client_activity(&self, scope: Scope) -> Result<Vec<ClientActivity>, StoreError> {
        // Queries aggregated per-client, per-day rows
    }
}

```

Callers receive compact `ClientActivity` structs suitable for dashboards, rate limiting, or billing without processing raw request logs.

---

## Complete Integration Example

The following pattern ties together recording, flushing, and retrieval:

```rust
use ai_memory_mcp::server::{ClientActivityBuffer, US_PER_DAY, CLIENT_ACTIVITY_FLUSH};
use ai_memory_store::Store;

// During MCP request handling
async fn handle_tool_call(
    buffer: Arc<ClientActivityBuffer>,
    client: String,
    tool: ToolCall,
) {
    let now_us = chrono::Utc::now().timestamp_micros();
    let utc_day = now_us / US_PER_DAY;
    let is_write = matches!(tool, ToolCall::Write(_));
    
    let should_spawn = buffer.record(client, utc_day, is_write);
    if should_spawn {
        let writer = /* obtain WriterHandle */;
        tokio::spawn(flush_client_activity_loop(
            buffer.clone(),
            writer,
            CLIENT_ACTIVITY_FLUSH,
        ));
    }
}

// Later, querying activity
async fn show_daily_stats(store: &Store, scope: Scope) -> Result<(), Box<dyn Error>> {
    let activities = store.client_activity(scope).await?;
    for a in activities {
        println!("{} | reads: {} | writes: {}", a.client, a.reads, a.writes);
    }
    Ok(())
}

```

---

## Summary

- **US_PER_DAY** (86,400,000,000 µs) converts timestamps to UTC day numbers for consistent bucketing
- **ClientActivityBuffer** in [`server.rs`](https://github.com/akitaonrails/ai-memory/blob/main/server.rs) holds `HashMap<(client, day), (reads, writes)>` with overflow protection
- **record / record_delta** update counters and signal flusher initialization
- **CLIENT_ACTIVITY_MAX_CLIENTS_PER_DAY** bounds memory; excess flows to `__overflow__`
- **flush_client_activity_loop** runs every 60 seconds, atomically extracting and persisting batches
- **bump_client_activity** in [`writer.rs`](https://github.com/akitaonrails/ai-memory/blob/main/writer.rs) delegates to SQLite UPSERTs in [`ops.rs`](https://github.com/akitaonrails/ai-memory/blob/main/ops.rs)
- **reader.rs** surfaces aggregated `ClientActivity` for downstream consumption

---

## Frequently Asked Questions

### What happens when too many distinct clients hit the same UTC day?

Once `CLIENT_ACTIVITY_MAX_CLIENTS_PER_DAY` distinct client names are recorded, additional unique clients are aggregated under the reserved `__overflow__` client name. This preserves total activity accuracy while capping memory usage. As implemented in [`crates/ai-memory-mcp/src/server.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-mcp/src/server.rs), the overflow bucket behaves identically to named clients for flushing and persistence.

### Why use microsecond-precision UTC rather than system time or local time?

Microsecond-precision UTC guarantees that `US_PER_DAY` divisions yield consistent day boundaries regardless of server timezone configuration or daylight saving transitions. As defined at line 23 of [`server.rs`](https://github.com/akitaonrails/ai-memory/blob/main/server.rs), this eliminates clock skew issues in distributed deployments and ensures that "day" means the same calendar day globally.

### Can the flush interval be tuned for lower latency?

The `CLIENT_ACTIVITY_FLUSH` constant (default 60 seconds) controls the `tokio::time::interval` in `flush_client_activity_loop`. While not exposed as runtime configuration in the current source, the modular structure in [`server.rs`](https://github.com/akitaonrails/ai-memory/blob/main/server.rs) lines 98-128 allows direct parameter substitution for deployments requiring more aggressive persistence.

### How does the system recover from failed flush attempts?

On `WriterHandle::bump_client_activity` failure, the flush loop invokes `buffer.restore(entries)` to return unwritten batches to the active buffer. The next interval iteration will retry the full batch. This at-least-once semantics ensures no activity is lost, though duplicate increments are possible if a failure occurs after SQLite commit but before acknowledgment—mitigated by the idempotent UPSERT design in [`ops.rs`](https://github.com/akitaonrails/ai-memory/blob/main/ops.rs).