Skip to main content

futu_backend/auth/commconfig/
runner.rs

1//! CommConfig background transaction actor.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use async_trait::async_trait;
7use futu_domain_auth::{
8    COMMCONFIG_RETRY_DELAY_MS_LIKE_CPP, CommConfigPageAction, plan_commconfig_page_action_like_cpp,
9    plan_commconfig_runtime_failure_like_cpp,
10};
11
12use super::accessors::delay_until_next_refresh;
13use super::clock::server_now_ts;
14use super::fetch_page::{CommConfigPageFetchError, fetch_page};
15use super::projection::project_common_config;
16use super::snapshot::SharedCommConfig;
17use super::store::{CommConfigStoreError, save_last_good};
18use super::transaction::{CommConfigTransaction, CommConfigTransactionStep, CommonConfigDocument};
19use super::types::CommConfigSource;
20use super::wire::decode_commconfig_page;
21
22/// C++ login waits up to 30 seconds for pre-login CommonConfig and allows
23/// login to continue when that request cannot be replayed.
24///
25/// Ref: `FutuOpenD/Src/FTGateway/FTGateway.cpp:288-291` passes `30000` with
26/// `bIgnoreWhenUnReplay=true`, and `:798-799` makes pull failure non-fatal.
27/// This fixed value is protocol-parity evidence, not server configuration;
28/// revise it if the upstream pre-login wait contract changes.
29pub const COMMCONFIG_PRE_LOGIN_TIMEOUT: Duration = Duration::from_secs(30);
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum CommConfigPersistence {
33    Persisted,
34    MemoryOnly,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum CommConfigBootstrapOutcome {
39    Published { persistence: CommConfigPersistence },
40    Dropped,
41    TimedOut,
42}
43
44#[async_trait]
45pub(super) trait CommConfigPageSource: Send + Sync {
46    async fn fetch_page(
47        &self,
48        begin_id: i32,
49    ) -> Result<serde_json::Value, CommConfigPageFetchError>;
50}
51
52struct ReqwestCommConfigPageSource {
53    http: reqwest::Client,
54    client_type: u8,
55    device_id: String,
56    user_id: u64,
57    svr_time_offset: i64,
58}
59
60#[async_trait]
61impl CommConfigPageSource for ReqwestCommConfigPageSource {
62    async fn fetch_page(
63        &self,
64        begin_id: i32,
65    ) -> Result<serde_json::Value, CommConfigPageFetchError> {
66        fetch_page(
67            &self.http,
68            self.client_type,
69            &self.device_id,
70            self.user_id,
71            begin_id,
72            self.svr_time_offset,
73        )
74        .await
75    }
76}
77
78#[derive(Debug, PartialEq)]
79pub(super) enum CommConfigRunOutcome {
80    Dropped,
81    Committed {
82        document: CommonConfigDocument,
83        next_refresh_ts: i64,
84    },
85}
86
87pub(super) async fn run_transaction<S>(source: &S, svr_time_offset: i64) -> CommConfigRunOutcome
88where
89    S: CommConfigPageSource + ?Sized,
90{
91    let mut transaction = CommConfigTransaction::new();
92    loop {
93        let begin_id = transaction.current_begin_id();
94        let json = match source.fetch_page(begin_id).await {
95            Ok(json) => json,
96            Err(error) => {
97                let action = plan_commconfig_runtime_failure_like_cpp(error.runtime_failure());
98                let CommConfigPageAction::RetrySamePage { delay_ms, reason } = action else {
99                    unreachable!("runtime failure policy must retry the same page");
100                };
101                tracing::warn!(
102                    error = %error,
103                    begin_id,
104                    ?reason,
105                    delay_ms,
106                    "commconfig: page fetch failed; retrying same page"
107                );
108                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
109                continue;
110            }
111        };
112        let page = decode_commconfig_page(&json);
113        let ret_code = page.facts.ret_code;
114        let ret_msg = page.ret_msg.clone();
115        let action = plan_commconfig_page_action_like_cpp(page.facts);
116        let step = transaction.apply_page(page, action, server_now_ts(svr_time_offset));
117        match step {
118            CommConfigTransactionStep::RetainedForRetry { begin_id } => {
119                let CommConfigPageAction::RetrySamePage { delay_ms, reason } = action else {
120                    unreachable!("retained transaction requires retry action");
121                };
122                tracing::warn!(
123                    ?ret_code,
124                    ?ret_msg,
125                    begin_id,
126                    ?reason,
127                    delay_ms,
128                    "commconfig: retryable business response; retrying same page"
129                );
130                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
131            }
132            CommConfigTransactionStep::Dropped => {
133                tracing::warn!(
134                    ?ret_code,
135                    ?ret_msg,
136                    ?action,
137                    "commconfig: dropping incomplete transaction"
138                );
139                return CommConfigRunOutcome::Dropped;
140            }
141            CommConfigTransactionStep::FetchNext { .. } => {}
142            CommConfigTransactionStep::Committed {
143                document,
144                next_refresh_ts,
145            } => {
146                return CommConfigRunOutcome::Committed {
147                    document,
148                    next_refresh_ts,
149                };
150            }
151        }
152    }
153}
154
155pub(super) fn publish_live_snapshot(
156    shared: &SharedCommConfig,
157    document: &CommonConfigDocument,
158    next_refresh_ts: i64,
159) {
160    let generation = shared.load().generation.saturating_add(1);
161    let snapshot = project_common_config(
162        document,
163        next_refresh_ts,
164        CommConfigSource::Live,
165        generation,
166    );
167    shared.store(Arc::new(snapshot));
168}
169
170fn persist_and_publish_complete_transaction(
171    shared: &SharedCommConfig,
172    document: &CommonConfigDocument,
173    next_refresh_ts: i64,
174    persist: impl FnOnce(&CommonConfigDocument) -> Result<(), CommConfigStoreError>,
175) -> CommConfigPersistence {
176    let persistence = match persist(document) {
177        Ok(()) => CommConfigPersistence::Persisted,
178        Err(error) => {
179            tracing::warn!(
180                error = %error,
181                "commconfig: failed to persist complete last-good; publishing in memory"
182            );
183            CommConfigPersistence::MemoryOnly
184        }
185    };
186    publish_live_snapshot(shared, document, next_refresh_ts);
187    let current = shared.load();
188    tracing::info!(
189        generation = current.generation,
190        platform_pools = current.guaranteed_ip.len(),
191        broker_pools = current.guaranteed_ip_broker.len(),
192        web_pools = current.guaranteed_ip_web.len(),
193        next_refresh_ts,
194        ?persistence,
195        "commconfig: fetched complete transaction and published"
196    );
197    persistence
198}
199
200pub(super) async fn bootstrap_from_source_with_persistence<S>(
201    shared: &SharedCommConfig,
202    source: &S,
203    svr_time_offset: i64,
204    deadline: Duration,
205    persist: impl FnOnce(&CommonConfigDocument) -> Result<(), CommConfigStoreError>,
206) -> CommConfigBootstrapOutcome
207where
208    S: CommConfigPageSource + ?Sized,
209{
210    match tokio::time::timeout(deadline, run_transaction(source, svr_time_offset)).await {
211        Err(_) => {
212            tracing::warn!(
213                deadline_secs = deadline.as_secs(),
214                "commconfig: pre-login transaction timed out; retaining last-good"
215            );
216            CommConfigBootstrapOutcome::TimedOut
217        }
218        Ok(CommConfigRunOutcome::Dropped) => CommConfigBootstrapOutcome::Dropped,
219        Ok(CommConfigRunOutcome::Committed {
220            document,
221            next_refresh_ts,
222        }) => CommConfigBootstrapOutcome::Published {
223            persistence: persist_and_publish_complete_transaction(
224                shared,
225                &document,
226                next_refresh_ts,
227                persist,
228            ),
229        },
230    }
231}
232
233pub async fn bootstrap_before_login(
234    shared: &SharedCommConfig,
235    http: reqwest::Client,
236    client_type: u8,
237    device_id: String,
238    user_id: u64,
239    svr_time_offset: i64,
240) -> CommConfigBootstrapOutcome {
241    let source = ReqwestCommConfigPageSource {
242        http,
243        client_type,
244        device_id,
245        user_id,
246        svr_time_offset,
247    };
248    bootstrap_from_source_with_persistence(
249        shared,
250        &source,
251        svr_time_offset,
252        COMMCONFIG_PRE_LOGIN_TIMEOUT,
253        save_last_good,
254    )
255    .await
256}
257
258pub fn spawn_actor(
259    shared: SharedCommConfig,
260    http: reqwest::Client,
261    client_type: u8,
262    device_id: String,
263    user_id: u64,
264    svr_time_offset: i64,
265) -> tokio::task::JoinHandle<()> {
266    tokio::spawn(async move {
267        let source = ReqwestCommConfigPageSource {
268            http,
269            client_type,
270            device_id,
271            user_id,
272            svr_time_offset,
273        };
274        tokio::time::sleep(Duration::from_millis(COMMCONFIG_RETRY_DELAY_MS_LIKE_CPP)).await;
275
276        loop {
277            let sleep_secs = match run_transaction(&source, svr_time_offset).await {
278                CommConfigRunOutcome::Committed {
279                    document,
280                    next_refresh_ts,
281                } => {
282                    persist_and_publish_complete_transaction(
283                        &shared,
284                        &document,
285                        next_refresh_ts,
286                        save_last_good,
287                    );
288                    delay_until_next_refresh(next_refresh_ts, server_now_ts(svr_time_offset))
289                }
290                CommConfigRunOutcome::Dropped => {
291                    delay_until_next_refresh(0, server_now_ts(svr_time_offset))
292                }
293            };
294            tokio::time::sleep(Duration::from_secs(sleep_secs)).await;
295        }
296    })
297}