use crate::module::ImModule;
use crate::state::{ChannelId, CorrelationContext, Seq};
use helix_core::effect::HttpRequest;
use helix_core::tick::PortOutcome;
use helix_core::{Correlation, Effect, EffectSink};
use serde_json::json;
use std::collections::{HashMap, HashSet, VecDeque};
const PAGE_SIZE: usize = 64;
#[derive(Debug)]
pub(crate) struct IncrementPull {
connection_id: String,
timestamp: i64,
cursors: HashMap<ChannelId, Seq>,
remaining: VecDeque<ChannelId>,
requested: Option<Vec<ChannelId>>,
inflight: Correlation,
pending: Vec<crate::sync_session::IncrementChannel>,
}
impl ImModule {
pub(crate) fn start_increment_pull(
&mut self,
timestamp: i64,
cursors: Vec<(ChannelId, Seq)>,
out: &mut EffectSink,
) {
if self.state.increment_pull.is_some() {
return;
}
let Some(connection_id) = self.state.connection_id.clone() else {
return;
};
let corr = self.alloc_corr_internal();
self.state.increment_pull = Some(IncrementPull {
connection_id,
timestamp,
cursors: cursors.into_iter().collect(),
remaining: VecDeque::new(),
requested: None,
inflight: corr,
pending: Vec::new(),
});
self.send_increment_pull_page(corr, out);
}
fn send_increment_pull_page(&mut self, corr: Correlation, out: &mut EffectSink) {
self.start_page_observation();
let Some(pull) = self.state.increment_pull.as_ref() else {
return;
};
let mut cursors: Vec<_> = match &pull.requested {
Some(ids) => ids
.iter()
.filter_map(|id| pull.cursors.get(id).map(|seq| (*id, *seq)))
.collect(),
None => pull.cursors.iter().map(|(id, seq)| (*id, *seq)).collect(),
};
cursors.sort_unstable_by_key(|(id, _)| *id);
let mut body = json!({"timeStamp":pull.timestamp,"cursors":cursors.iter().map(|(id,seq)|json!({"channelId":id.as_str(),"fromSeq":seq.0})).collect::<Vec<_>>()});
if let Some(ids) = &pull.requested {
body["channelIds"] = json!(ids.iter().map(ChannelId::as_str).collect::<Vec<_>>());
}
self.diagnose(crate::diagnostics::Observation {
event: "increment_page_requested",
stage: "http",
corr: Some(corr.raw()),
count: pull.requested.as_ref().map_or(0, Vec::len),
has_more: !pull.remaining.is_empty(),
..Default::default()
});
let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())];
headers.extend(crate::acl::sync_http_effects::session_auth_headers(Some(
&pull.connection_id,
)));
headers.push(("X-CSES-Sync-Session-Id".into(), pull.connection_id.clone()));
headers.push((
"X-CSES-Sync-Page-Index".into(),
self.sync_timing.page_index.to_string(),
));
out.push(Effect::Http {
corr,
req: HttpRequest {
method: "POST".into(),
url: format!("{}/channels/load/increment/page", self.config.api_base_url),
headers,
body: Some(body.to_string().into()),
},
});
self.state
.corr_map
.insert(corr, CorrelationContext::IncrementPullHttp);
}
pub(crate) fn handle_increment_pull_http(
&mut self,
corr: Correlation,
outcome: &PortOutcome,
now_ms: u64,
out: &mut EffectSink,
) {
if !self.increment_pull_matches(corr) {
return;
}
let parsed = (|| {
let PortOutcome::Ok(reply) = outcome else {
return None;
};
let raw = crate::http_envelope::unwrap_sync_envelope(reply.0.as_ref()).ok()?;
let value: serde_json::Value = serde_json::from_slice(&raw).ok()?;
if value.get("status")?.as_str()? != "SUCCESS" {
return None;
}
let data = value.get("data")?;
let channels = data.get("channels")?.as_array()?;
if channels.len() > PAGE_SIZE {
return None;
}
let pull = self.state.increment_pull.as_ref()?;
let manifest = if pull.requested.is_none() {
let mut seen = HashSet::new();
let ids = data
.get("manifest")?
.as_array()?
.iter()
.map(|value| {
let id = ChannelId::from_str(value.as_str()?)?;
seen.insert(id).then_some(id)
})
.collect::<Option<Vec<_>>>()?;
Some(ids)
} else {
None
};
let expected: HashSet<_> = manifest
.as_ref()
.map(|ids| ids.iter().take(PAGE_SIZE).copied().collect())
.unwrap_or_else(|| {
pull.requested
.as_ref()
.into_iter()
.flatten()
.copied()
.collect()
});
let mut seen = HashSet::new();
let increments = channels
.iter()
.map(|value| {
let inc = crate::ws::parser::parse_increment_channel(value)?;
(expected.contains(&inc.channel_id) && seen.insert(inc.channel_id))
.then_some(inc)
})
.collect::<Option<Vec<_>>>()?;
if manifest.is_some() && increments.len() != expected.len() {
return None;
}
Some((manifest, increments))
})();
let Some((manifest, increments)) = parsed else {
self.fail_increment_pull("invalid_or_failed_http");
return;
};
if let Some(manifest) = manifest {
self.sync_timing.total_pages = Some(manifest.len().div_ceil(PAGE_SIZE).max(1) as u64);
if let Some(pull) = self.state.increment_pull.as_mut() {
pull.remaining = manifest.into_iter().skip(PAGE_SIZE).collect();
}
}
let mut ops = Vec::new();
let base = self.config.api_base_url.clone();
let actor = self.config.auth_user_id.clone();
self.with_state_and_corr_allocator(|state, alloc| {
let mut ctx = crate::ws::ImWsContext::new(state, now_ms, &base, &actor, alloc);
for inc in &increments {
for effect in
crate::ws::handlers::increment_channel::compile_increment_effects(&mut ctx, inc)
{
if let Effect::PersistFire { ops: writes } = effect {
ops.extend(writes);
}
}
}
});
if let Some(pull) = self.state.increment_pull.as_mut() {
pull.pending = increments;
}
if ops.is_empty() {
self.advance_increment_pull(out);
return;
}
let persist = self.alloc_corr_internal();
if let Some(pull) = self.state.increment_pull.as_mut() {
pull.inflight = persist;
}
self.state
.corr_map
.insert(persist, CorrelationContext::IncrementPullPersist);
out.push(Effect::PersistAtomic { corr: persist, ops });
}
pub(crate) fn handle_increment_pull_persist(
&mut self,
corr: Correlation,
outcome: &PortOutcome,
out: &mut EffectSink,
) {
if !self.increment_pull_matches(corr) {
return;
}
self.diagnose(crate::diagnostics::Observation {
event: "increment_page_persisted",
stage: "persist",
corr: Some(corr.raw()),
count: self
.state
.increment_pull
.as_ref()
.map_or(0, |pull| pull.pending.len()),
result: if matches!(outcome, PortOutcome::Ok(_)) {
"success"
} else {
"failed"
},
..Default::default()
});
if matches!(outcome, PortOutcome::Ok(_)) {
self.advance_increment_pull(out);
} else {
self.fail_increment_pull("persist_failed");
}
}
fn increment_pull_matches(&self, corr: Correlation) -> bool {
self.state.conn == crate::state::ConnState::Connected
&& self.state.increment_pull.as_ref().is_some_and(|pull| {
pull.inflight == corr
&& self.state.connection_id.as_deref() == Some(pull.connection_id.as_str())
})
}
fn advance_increment_pull(&mut self, out: &mut EffectSink) {
self.finish_page_observation(crate::sync_observation::SyncResult::Success);
let pending = match self.state.increment_pull.as_mut() {
Some(pull) => std::mem::take(&mut pull.pending),
None => return,
};
let base = self.config.api_base_url.clone();
let actor = self.config.auth_user_id.clone();
self.with_state_and_corr_allocator(|state, alloc| {
let mut ctx = crate::ws::ImWsContext::new(state, 0, &base, &actor, alloc);
for inc in &pending {
crate::ws::handlers::increment_channel::commit_increment_state(&mut ctx, inc);
}
});
for inc in &pending {
self.diagnose(crate::diagnostics::Observation {
event: "channel_inventory_item_committed",
stage: "persist",
result: "success",
channel: inc.channel_id.as_str(),
count: 1,
..Default::default()
});
self.diagnose_checkpoint(inc.channel_id, "inventory_committed");
}
let Some(pull) = self.state.increment_pull.as_mut() else {
return;
};
if pull.remaining.is_empty() {
self.state.increment_pull = None;
if self.state.increment_fetched.is_empty() {
let corr = self.alloc_corr_internal();
self.state.channel_sync_batch_pending = false;
self.state.channel_sync_persist_inflight += 1;
self.state.corr_map.insert(
corr,
CorrelationContext::IncrementBatchPersist {
projections: Vec::new(),
batch_id: None,
},
);
out.push(Effect::PersistAtomic {
corr,
ops: Vec::new(),
});
} else {
self.ingest_increment_end(None, out);
}
return;
}
pull.requested = Some(
(0..PAGE_SIZE)
.filter_map(|_| pull.remaining.pop_front())
.collect(),
);
let corr = self.alloc_corr_internal();
if let Some(pull) = self.state.increment_pull.as_mut() {
pull.inflight = corr;
}
self.send_increment_pull_page(corr, out);
}
fn fail_increment_pull(&mut self, reason: &'static str) {
self.finish_sync_observation(crate::sync_observation::SyncResult::Failed);
self.state.increment_pull = None;
self.state.channel_sync_batch_pending = false;
self.state.recovery_session.phase = crate::sync_session::RecoveryPhase::Failed;
tracing::warn!(reason, "increment pull stopped before completion");
}
}