tidaldb/docs/planning/milestone-8/phase-2/task-05-follower-db.md
jordan f4cfd6c81f feat: complete M8 replication primitives + forage enhancements + docs
Milestone 8 (phases 1-4):
- Shard-aware WAL segment naming, BatchHeader v2, ShardRouter
- Transport trait, InProcessTransport, WalShipper, FollowerDb
- HLC, PNCounter, LWWRegister, CrdtSignalState, ReconciliationEngine
- Session replication bridge with SeqNo/HWM, idempotency store

Forage application:
- Multi-source discovery engine with MAB exploration
- Embedding-based label system, server handlers, UI refresh

Other:
- QUICKSTART.md, README.md, milestone-8 planning docs
- Hard negative union semantics, RLHF export enhancements
- Recovery benchmark and visibility test expansions
- Split 8 oversized source files per CODING_GUIDELINES §9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:17:19 -07:00

3.6 KiB

Task 05: FollowerDb

Delivers

Wire TidalDb to support NodeRole::Follower startup in tidal/src/db/open.rs. Guard all write methods (signal, write_item, write_creator, etc.) to return TidalError::ReadOnly when role is Follower. Start SegmentReceiver on open for follower nodes.

Complexity: M

Dependencies

  • Task 04 (SegmentReceiver)
  • Phase 8.1 (NodeConfig, NodeRole)

Technical Design

Write guards in TidalDb

// tidal/src/db/mod.rs

impl TidalDb {
    /// Guard that returns ReadOnly if this node is a follower.
    fn require_writeable(&self) -> crate::Result<()> {
        if !self.config.cluster.accepts_writes() {
            return Err(TidalError::ReadOnly);
        }
        Ok(())
    }

    pub fn signal(
        &self,
        signal_type: &str,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
    ) -> crate::Result<()> {
        self.require_writeable()?;
        // ... existing implementation ...
    }

    pub fn write_item(
        &self,
        entity_id: EntityId,
        metadata: &HashMap<String, String>,
    ) -> crate::Result<()> {
        self.require_writeable()?;
        // ... existing implementation ...
    }

    // All other write methods follow the same pattern.
}

Follower startup in open.rs

// tidal/src/db/open.rs

pub fn open_db(config: Config) -> crate::Result<TidalDb> {
    // ... existing open logic ...

    let db = TidalDb { /* ... */ };

    if config.cluster.role == NodeRole::Follower {
        // Start segment receiver background task.
        // The transport is set by the caller via db.start_replication(transport).
        tracing::info!("TidalDb: starting as follower for shard {:?}", config.cluster.shard_id);
    }

    Ok(db)
}

TidalDb::start_replication

impl TidalDb {
    /// Wire up replication transport for follower nodes.
    ///
    /// Must be called after open() for NodeRole::Follower nodes.
    /// No-op for NodeRole::Single or NodeRole::Leader.
    pub fn start_replication(
        &self,
        transport: Arc<dyn Transport>,
        leader_shard: ShardId,
        shutdown_rx: tokio::sync::watch::Receiver<bool>,
    ) {
        if self.config.cluster.role != NodeRole::Follower {
            return;
        }
        let receiver = Arc::new(SegmentReceiver::new(
            transport,
            Arc::clone(&self.signal_ledger),
            Arc::clone(&self.replication_state),
            leader_shard,
        ));
        receiver.start(shutdown_rx);
    }
}

TidalError::ReadOnly

// tidal/src/error.rs (or wherever TidalError is defined)

#[derive(Debug, thiserror::Error)]
pub enum TidalError {
    // ... existing variants ...

    /// This node is a read-only follower; write operations are not permitted.
    #[error("this node is read-only (follower)")]
    ReadOnly,
}

Acceptance Criteria

  • TidalError::ReadOnly variant added to the error enum
  • All write methods (signal, write_item, write_creator, write_item_embedding, write_creator_embedding, close_session, etc.) return Err(TidalError::ReadOnly) when role == Follower
  • Read methods (retrieve, search, read_decay_score, etc.) work normally on followers
  • TidalDb::start_replication(transport, leader_shard, shutdown_rx) wires SegmentReceiver for follower nodes; is a no-op for Single/Leader
  • Integration test: open as Follower, verify all writes fail with ReadOnly; open as Leader, verify writes succeed
  • All existing tests pass (they use Single node, unaffected)
  • cargo clippy -D warnings and cargo fmt pass