use helix_core::effect::{BatchUpdateSpec, SqlValue, StorageOp};
use helix_core::{Effect, EffectSink};
use crate::error::ImError;
use crate::state::ChannelId;
use super::super::{ImWsContext, WsFrame, WsHandlerRegistration, WsMessageHandler};
const INCREMENT_CHANNEL_END_ACTION: &str = "increment_channel_end";
pub(crate) fn apply_increment_end(
ctx: &mut ImWsContext<'_>,
channel_id: Option<ChannelId>,
out: &mut EffectSink,
) {
ctx.state.pending_increment_batch_id = None;
match channel_id {
Some(channel_id) => {
tracing::info!(
channel_id = %channel_id.as_str(),
"channel-sync-ready 未触发:收到带 channelId 的单频道结束帧"
);
out.push(crate::acl::to_effect::emit_channel_update(channel_id));
}
None => {
if !ctx.state.channel_sync_batch_pending {
tracing::info!(
"channel-sync-ready 未触发:当前 global increment 批次没有新的 increment 帧"
);
return;
}
ctx.state.channel_sync_batch_pending = false;
let mut targets = Vec::with_capacity(ctx.state.increment_fetched.len());
let mut seen =
std::collections::HashSet::with_capacity(ctx.state.increment_fetched.len());
for &channel_id in &ctx.state.increment_order {
if ctx.state.increment_fetched.contains(&channel_id)
&& !ctx.state.need_sync_skip.contains(&channel_id)
&& seen.insert(channel_id)
{
targets.push(channel_id);
}
}
let mut unordered_tail: Vec<ChannelId> = ctx
.state
.increment_fetched
.iter()
.filter(|id| !ctx.state.need_sync_skip.contains(*id) && !seen.contains(*id))
.copied()
.collect();
unordered_tail.sort_unstable();
for channel_id in unordered_tail {
if seen.insert(channel_id) {
targets.push(channel_id);
}
}
for channel_id in collect_incomplete_backfill_targets(ctx) {
if seen.insert(channel_id) {
targets.push(channel_id);
}
}
emit_proactive_resync_for(ctx, &targets, out);
let todo_post_ids = std::mem::take(&mut ctx.state.about_me_post_ids);
trigger_todo_query(ctx, &todo_post_ids, out);
let mut ops = std::mem::take(&mut ctx.state.pending_increment_ops);
let projections = std::mem::take(&mut ctx.state.pending_increment_projections);
let mut cursor_channels: Vec<ChannelId> =
ctx.state.increment_fetched.iter().copied().collect();
cursor_channels.sort_unstable();
for channel_id in cursor_channels {
if let Some(channel) = ctx.state.channels.get(&channel_id) {
ops.push(crate::acl::to_effect::advance_cursor_op(
channel_id,
channel.cursor.value(),
));
}
}
if ops.is_empty() && projections.is_empty() {
tracing::info!(
"channel-sync-ready 未触发:global increment_channel_end 没有可持久化的批次内容"
);
return;
}
tracing::info!(
pending_ops = ops.len(),
projections = projections.len(),
"channel-sync-ready 等待 increment 批次 Persist 回执"
);
let corr = ctx.alloc_corr();
ctx.state.channel_sync_persist_inflight =
ctx.state.channel_sync_persist_inflight.saturating_add(1);
out.push(Effect::PersistAtomic { corr, ops });
ctx.state.corr_map.insert(
corr,
crate::state::CorrelationContext::IncrementBatchPersist { projections },
);
}
}
}
pub(crate) fn apply_scoped_increment_end(
ctx: &mut ImWsContext<'_>,
channel_id: Option<ChannelId>,
scope: Option<&str>,
batch_id: Option<&str>,
out: &mut EffectSink,
) {
match scope {
None => {
apply_increment_end(ctx, channel_id, out);
return;
}
Some("channels") => {
if channel_id.is_some() {
tracing::warn!(
"channel-sync-ready 未触发:scope=channels 不允许携带 parent channelId"
);
return;
}
let batch_id = batch_id.filter(|value| !value.is_empty());
if let Some(expected) = ctx.state.pending_increment_batch_id.as_deref() {
if batch_id != Some(expected) {
tracing::warn!(
expected_batch_id = expected,
batch_id = batch_id.unwrap_or("<missing>"),
"channel-sync-ready 未触发:普通频道收尾 batchId 不匹配"
);
return;
}
}
apply_increment_end(ctx, None, out);
return;
}
Some("subtopics") if channel_id.is_some() => {}
Some(other) => {
tracing::warn!(
scope = other,
"subtopics-sync-ready 未触发:未知或不完整的 scope"
);
return;
}
}
let parent_channel_id = channel_id.expect("checked above");
let batch_id = batch_id
.filter(|value| !value.is_empty())
.unwrap_or("legacy");
if let Some(expected) = ctx.state.pending_increment_batch_id.as_deref() {
if expected != batch_id {
tracing::warn!(
channel_id = %parent_channel_id.as_str(),
expected_batch_id = expected,
batch_id,
"subtopics-sync-ready 未触发:收尾 batchId 不匹配"
);
return;
}
}
ctx.state.pending_increment_batch_id = None;
let batch_key = format!("subtopics|{}|{}", parent_channel_id.as_str(), batch_id);
if ctx.state.subtopic_sync_completed.contains(&batch_key)
|| ctx.state.subtopic_sync_active.as_deref() == Some(batch_key.as_str())
{
tracing::info!(
channel_id = %parent_channel_id.as_str(),
batch_id,
"subtopics-sync-ready 未触发:重复或在途批次"
);
return;
}
ctx.state.subtopic_sync_active = Some(batch_key.clone());
let mut ops = std::mem::take(&mut ctx.state.pending_increment_ops);
let projections = std::mem::take(&mut ctx.state.pending_increment_projections);
ops.push(StorageOp::BatchUpdate(BatchUpdateSpec {
table: "channel",
key_col: "id",
key_vals: vec![SqlValue::Text(parent_channel_id.as_str().to_string())],
patch: vec![(
"subtopics_loaded_at".to_string(),
SqlValue::Integer(ctx.now_ms.min(i64::MAX as u64) as i64),
)],
}));
let corr = ctx.alloc_corr();
out.push(Effect::Persist { corr, ops });
ctx.state.corr_map.insert(
corr,
crate::state::CorrelationContext::SubtopicIncrementBatchPersist {
parent_channel_id,
batch_key,
projections,
},
);
tracing::info!(
channel_id = %parent_channel_id.as_str(),
batch_id,
"subtopics-sync-ready 等待话题 increment 批次 Persist 回执"
);
}
fn trigger_todo_query(ctx: &mut ImWsContext<'_>, post_ids: &[String], out: &mut EffectSink) {
if post_ids.is_empty() {
return;
}
let body = crate::todo::query_todo_body(post_ids);
let corr = ctx.alloc_corr();
let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())];
headers.extend(crate::acl::sync_http_effects::session_auth_headers(
ctx.state.connection_id.as_deref(),
));
out.push(helix_core::effect::Effect::Http {
corr,
req: helix_core::effect::HttpRequest {
method: "POST".to_string(),
url: format!("{}/posts/queryTodoList", ctx.api_base_url),
headers,
body: Some(bytes::Bytes::from(
serde_json::to_vec(&body).unwrap_or_default(),
)),
},
});
ctx.state
.corr_map
.insert(corr, crate::state::CorrelationContext::TodoQuery);
tracing::info!("helix-im UC-10: hello 收尾触发 queryTodoList(待办内容拉取)");
}
pub(crate) fn emit_proactive_resync_for(
ctx: &mut ImWsContext<'_>,
targets: &[ChannelId],
out: &mut EffectSink,
) {
let api_base_url = ctx.api_base_url;
let (state, alloc) = ctx.split_state_alloc();
crate::sync_scheduler::enqueue_and_drain(state, api_base_url, targets, alloc, out);
}
fn collect_incomplete_backfill_targets(ctx: &mut ImWsContext<'_>) -> Vec<ChannelId> {
if ctx.state.backfill_healed {
return Vec::new();
}
ctx.state.backfill_healed = true;
let mut stuck: Vec<ChannelId> = ctx
.state
.increment_fetched
.iter()
.filter_map(|&id| {
let target = ctx.state.increment_target.get(&id).copied()?;
let cursor = ctx.state.channels.get(&id)?.cursor.value();
(cursor < target).then_some(id)
})
.collect();
if stuck.is_empty() {
return stuck;
}
stuck.sort_unstable();
tracing::info!(
stuck_channels = stuck.len(),
"helix-im B2: 冷启动自愈——对 local cursor 落后服务端 increment 水位的 channel 补发 sync"
);
stuck
}
fn parse_channel_id(frame: &WsFrame) -> Option<ChannelId> {
frame
.data()
.and_then(|data| data.get("channelId"))
.and_then(serde_json::Value::as_str)
.and_then(ChannelId::from_str)
}
struct IncrementChannelEndHandler;
impl WsMessageHandler for IncrementChannelEndHandler {
fn action(&self) -> &'static str {
INCREMENT_CHANNEL_END_ACTION
}
fn handle(
&self,
ctx: &mut ImWsContext<'_>,
frame: &WsFrame,
out: &mut EffectSink,
) -> Result<(), ImError> {
let channel_id = parse_channel_id(frame);
let (scope, batch_id) = frame
.data()
.map(|data| {
(
data.get("scope").and_then(serde_json::Value::as_str),
data.get("batchId").and_then(serde_json::Value::as_str),
)
})
.unwrap_or((None, None));
apply_scoped_increment_end(ctx, channel_id, scope, batch_id, out);
Ok(())
}
}
static INCREMENT_CHANNEL_END_HANDLER: IncrementChannelEndHandler = IncrementChannelEndHandler;
#[cfg(target_arch = "wasm32")]
pub(super) fn inventory_link_anchor() {
std::hint::black_box(&INCREMENT_CHANNEL_END_HANDLER);
}
inventory::submit! {
WsHandlerRegistration {
action: INCREMENT_CHANNEL_END_ACTION,
handler: &INCREMENT_CHANNEL_END_HANDLER,
}
}