use crate::error::ImError;
use crate::module::ImModule;
use crate::state::{ChannelId, Seq};
use helix_core::EffectSink;
impl ImModule {
pub(crate) fn handle_increment_message_timestamp_scan_reply(
&mut self,
connection_id: Option<String>,
cursors: Vec<(ChannelId, Seq)>,
outcome: &helix_core::tick::PortOutcome,
out: &mut EffectSink,
) -> Result<(), ImError> {
if self.state.conn != crate::state::ConnState::Connected
|| self.state.connection_id.as_deref() != connection_id.as_deref()
{
tracing::debug!(
expected_connection_id = ?connection_id,
active_connection_id = ?self.state.connection_id,
"ignoring stale increment message timestamp scan reply"
);
return Ok(());
}
let timestamp = match outcome {
helix_core::tick::PortOutcome::Ok(reply) => {
parse_latest_message_update_at(reply.0.as_ref())
}
helix_core::tick::PortOutcome::Err(error) => {
tracing::warn!(
error = ?error,
"message watermark scan failed; increment timestamp falls back to zero"
);
0
}
};
if self.state.increment_page_supported {
self.start_increment_pull(timestamp, cursors, out);
return Ok(());
}
let increment_corr = self.alloc_corr_internal();
out.push(crate::acl::to_effect::increment_http_trigger(
&self.config.api_base_url,
timestamp,
&cursors,
increment_corr,
self.state.connection_id.as_deref(),
));
Ok(())
}
pub(crate) fn handle_scan_reply(
&mut self,
reply: &helix_core::tick::ReplyBytes,
out: &mut EffectSink,
) -> Result<(), ImError> {
let rows: Vec<serde_json::Value> = if reply.0.is_empty() {
vec![]
} else {
serde_json::from_slice(reply.0.as_ref()).unwrap_or_else(|e| {
tracing::warn!(
error = %e,
raw = %String::from_utf8_lossy(reply.0.as_ref()),
"scan reply JSON parse failed, treating as empty rows"
);
vec![]
})
};
for row in &rows {
let channel_id = match row["channel_id"].as_str().and_then(ChannelId::from_str) {
Some(id) => id,
None => {
tracing::warn!(
channel_id = ?row["channel_id"],
"scan row channel_id not a valid 26-char channel_id, skipping"
);
continue;
}
};
let cursor = match row["last_event_seq"].as_i64() {
Some(v) if v >= 0 => v as u64,
_ => 0u64,
};
let terminal_seq = match row["terminal_event_seq"].as_i64() {
Some(v) if v > 0 => Some(crate::state::Seq(v as u64)),
Some(0) | None => None,
_ => {
tracing::warn!(
channel_id = channel_id.as_str(),
terminal_event_seq = ?row["terminal_event_seq"],
"terminal tombstone marker is invalid; suppressing marker"
);
None
}
};
let channel = self
.state
.channels
.entry(channel_id)
.or_insert_with(|| crate::channel::Channel::new(channel_id, cursor));
restore_scanned_cursor(channel, Seq(cursor));
if let Some(terminal_seq) = terminal_seq {
if !channel.restore_terminal(terminal_seq) {
tracing::error!(
channel_id = channel_id.as_str(),
cursor = channel.cursor.value().0,
terminal_event_seq = terminal_seq.0,
"terminal tombstone is ahead of cursor; channel remains non-terminal and is not safe to resync"
);
channel.terminal_event_seq = Some(channel.cursor.value());
}
}
}
tracing::info!(
channel_count = rows.len(),
"scan_corr resolved, loaded {} channels from cursor store",
rows.len()
);
self.request_channel_projection_scan(out);
Ok(())
}
pub(crate) fn handle_channel_projection_scan_reply(
&mut self,
reply: &helix_core::tick::ReplyBytes,
out: &mut EffectSink,
) -> Result<(), ImError> {
let rows: Vec<serde_json::Value> = if reply.0.is_empty() {
vec![]
} else {
match serde_json::from_slice(reply.0.as_ref()) {
Ok(rows) => rows,
Err(error) => {
tracing::warn!(
%error,
raw = %String::from_utf8_lossy(reply.0.as_ref()),
"channel projection scan JSON parse failed; startup sync remains fail-closed"
);
return Ok(());
}
}
};
let mut terminal_count = 0usize;
for row in &rows {
let Some(channel_id) = row
.get("id")
.or_else(|| row.get("channel_id"))
.and_then(serde_json::Value::as_str)
.and_then(ChannelId::from_str)
else {
continue;
};
let delete_at = row
.get("delete_at")
.or_else(|| row.get("deleteAt"))
.and_then(as_i64)
.unwrap_or(0);
let is_remove = row
.get("is_remove")
.or_else(|| row.get("isRemove"))
.and_then(as_bool)
.unwrap_or(false);
if delete_at <= 0 && !is_remove {
continue;
}
let projection_seq = row
.get("last_event_seq")
.or_else(|| row.get("lastEventSeq"))
.and_then(as_i64)
.unwrap_or(0)
.max(0) as u64;
if let Some(channel) = self.state.channels.get_mut(&channel_id) {
let terminal_seq = Seq(projection_seq.max(channel.cursor.value().0));
channel.mark_projection_terminal(terminal_seq);
terminal_count += 1;
}
}
tracing::info!(
channel_count = rows.len(),
terminal_count,
"channel projection scan resolved; deleted channels excluded from sync"
);
self.state.startup_channel_projection_ready = true;
self.finish_startup_scan(out);
Ok(())
}
}
fn parse_latest_message_update_at(bytes: &[u8]) -> i64 {
if bytes.is_empty() {
return 0;
}
let rows: Vec<serde_json::Value> = match serde_json::from_slice(bytes) {
Ok(rows) => rows,
Err(error) => {
tracing::warn!(
%error,
raw = %String::from_utf8_lossy(bytes),
"message watermark scan JSON parse failed; using timestamp zero"
);
return 0;
}
};
rows.first()
.and_then(|row| row.get("update_at"))
.and_then(serde_json::Value::as_i64)
.filter(|timestamp| *timestamp >= 0)
.unwrap_or(0)
}
fn restore_scanned_cursor(channel: &mut crate::channel::Channel, persisted: Seq) {
channel.cursor.try_advance(persisted);
}
fn as_i64(value: &serde_json::Value) -> Option<i64> {
value
.as_i64()
.or_else(|| value.as_u64().and_then(|v| i64::try_from(v).ok()))
}
fn as_bool(value: &serde_json::Value) -> Option<bool> {
value
.as_bool()
.or_else(|| value.as_i64().map(|v| v != 0))
.or_else(|| {
value.as_str().and_then(|v| match v {
"1" | "true" | "TRUE" => Some(true),
"0" | "false" | "FALSE" => Some(false),
_ => None,
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn startup_scan_restores_cursor_for_pre_registered_channel() {
let channel_id =
ChannelId::from_str("12345678901234567890123456").expect("valid channel id");
let mut channel = crate::channel::Channel::new(channel_id, 0);
restore_scanned_cursor(&mut channel, Seq(19));
assert_eq!(channel.cursor.value(), Seq(19));
}
#[test]
fn startup_scan_never_regresses_live_cursor() {
let channel_id =
ChannelId::from_str("12345678901234567890123456").expect("valid channel id");
let mut channel = crate::channel::Channel::new(channel_id, 23);
restore_scanned_cursor(&mut channel, Seq(19));
assert_eq!(channel.cursor.value(), Seq(23));
}
}