Skip to main content

futu_auth/limits/runtime/
batch.rs

1//! Split from runtime.rs: batch (atomic multi-leg daily receipt) admission.
2//!
3//! `BatchLimitGuard` commit / receipt release and `RuntimeCounters::check_batch_limits`.
4
5use super::*;
6use chrono::{DateTime, Utc};
7use sha2::{Digest, Sha256};
8use std::collections::BTreeMap;
9
10impl BatchLimitGuard<'_> {
11    /// Commit all currency buckets exactly once for this caller+intent.
12    pub fn commit_daily_once(self) -> Result<(), LimitOutcome> {
13        if self.pending_daily.is_empty() || self.already_committed {
14            return Ok(());
15        }
16        let mut receipts = self.counters.daily_receipts.lock();
17        // Sample the date only after receipt serialization. This gives
18        // midnight commits a total order: an old preflight can never write the
19        // prior date after a newer-date commit, even if no D+1 request raced it.
20        let commit_day = Utc::now().date_naive();
21        self.commit_daily_once_locked(&mut receipts, commit_day)
22    }
23
24    fn commit_daily_once_locked(
25        &self,
26        receipts: &mut HashMap<String, DailyReceipt>,
27        commit_day: NaiveDate,
28    ) -> Result<(), LimitOutcome> {
29        if commit_day < self.today {
30            return Err(stale_day_outcome(commit_day, self.today));
31        }
32        receipts.retain(|_, receipt| receipt.day >= commit_day);
33        if let Some(existing) = receipts.get(&self.receipt_key) {
34            return if existing.fingerprint == self.fingerprint {
35                Ok(())
36            } else {
37                Err(LimitOutcome::ValueReject(
38                    "daily quota receipt was reused with different order facts".into(),
39                ))
40            };
41        }
42        let counter = self
43            .counters
44            .counters
45            .entry(self.key_id.clone())
46            .or_insert_with(|| DailyCounter::new(commit_day));
47        counter
48            .try_add_batch(&self.pending_daily, self.daily_cap, commit_day)
49            .map_err(batch_daily_error)?;
50        receipts.insert(
51            self.receipt_key.clone(),
52            DailyReceipt {
53                fingerprint: self.fingerprint,
54                day: commit_day,
55                deltas: self.pending_daily.clone(),
56            },
57        );
58        Ok(())
59    }
60
61    #[must_use]
62    pub fn is_already_committed(&self) -> bool {
63        self.already_committed
64    }
65
66    #[cfg(test)]
67    pub(in crate::limits) fn commit_daily_once_on(
68        self,
69        commit_day: NaiveDate,
70    ) -> Result<(), LimitOutcome> {
71        if self.pending_daily.is_empty() || self.already_committed {
72            return Ok(());
73        }
74        let mut receipts = self.counters.daily_receipts.lock();
75        self.commit_daily_once_locked(&mut receipts, commit_day)
76    }
77}
78
79fn batch_fingerprint(contexts: &[CheckCtx]) -> [u8; 32] {
80    fn text(digest: &mut Sha256, value: Option<&str>) {
81        match value {
82            Some(value) => {
83                digest.update([1]);
84                digest.update((value.len() as u64).to_be_bytes());
85                digest.update(value.as_bytes());
86            }
87            None => digest.update([0]),
88        }
89    }
90    let mut digest = Sha256::new();
91    digest.update((contexts.len() as u64).to_be_bytes());
92    for context in contexts {
93        text(&mut digest, Some(&context.market));
94        text(&mut digest, Some(&context.symbol));
95        match context.order_value {
96            Some(value) => {
97                digest.update([1]);
98                digest.update(value.to_bits().to_be_bytes());
99            }
100            None => digest.update([0]),
101        }
102        text(&mut digest, context.trd_side.as_deref());
103        match context.acc_id {
104            Some(value) => {
105                digest.update([1]);
106                digest.update(value.to_be_bytes());
107            }
108            None => digest.update([0]),
109        }
110        digest.update([u8::from(context.mutation_no_exposure)]);
111        text(&mut digest, context.currency.as_deref());
112    }
113    digest.finalize().into()
114}
115
116impl RuntimeCounters {
117    /// Validate a complete group write and return one atomic daily receipt.
118    /// `receipt_id` must be the durable caller+intent identity shared by every
119    /// transport surface and the gateway writer.
120    pub fn check_batch_limits<'a>(
121        &'a self,
122        key_id: &str,
123        limits: &(impl LimitPolicy + ?Sized),
124        contexts: &[CheckCtx],
125        now: DateTime<Utc>,
126        commit_rate: bool,
127        receipt_id: &str,
128    ) -> Result<BatchLimitGuard<'a>, LimitOutcome> {
129        if receipt_id.is_empty() {
130            return Err(LimitOutcome::ValueReject(
131                "batch daily admission requires a durable receipt id".into(),
132            ));
133        }
134        for (index, context) in contexts.iter().enumerate() {
135            let mut non_committing = context.clone();
136            non_committing.mutation_no_exposure = true;
137            let _ = self.check_limits(
138                key_id,
139                limits,
140                &non_committing,
141                now,
142                commit_rate && index == 0,
143            )?;
144        }
145
146        let mut aggregate = BTreeMap::<Option<String>, f64>::new();
147        if limits.max_daily_value().is_some() {
148            for context in contexts {
149                if context.mutation_no_exposure {
150                    continue;
151                }
152                if let Some(value) = context.order_value {
153                    let currency = context.daily_currency();
154                    let next = aggregate.get(&currency).copied().unwrap_or(0.0) + value;
155                    validate_order_value(next).map_err(|reason| {
156                        LimitOutcome::ValueReject(format!(
157                            "order value invalid ({reason:?}) in batch aggregate"
158                        ))
159                    })?;
160                    aggregate.insert(currency, next);
161                }
162            }
163        }
164        let pending_daily = aggregate.into_iter().collect::<Vec<_>>();
165        let fingerprint = batch_fingerprint(contexts);
166        let receipt_key = format!("{}\0{}", key_id, receipt_id);
167        let already_committed = {
168            let mut receipts = self.daily_receipts.lock();
169            receipts.retain(|_, receipt| receipt.day >= now.date_naive());
170            match receipts.get(&receipt_key) {
171                Some(existing) if existing.fingerprint == fingerprint => true,
172                Some(_) => {
173                    return Err(LimitOutcome::ValueReject(
174                        "daily quota receipt was reused with different order facts".into(),
175                    ));
176                }
177                None => false,
178            }
179        };
180        if !already_committed && !pending_daily.is_empty() {
181            let counter = self
182                .counters
183                .entry(key_id.to_string())
184                .or_insert_with(|| DailyCounter::new(now.date_naive()));
185            counter
186                .peek_add_batch(&pending_daily, limits.max_daily_value(), now.date_naive())
187                .map_err(batch_daily_error)?;
188        }
189        Ok(BatchLimitGuard {
190            counters: self,
191            key_id: key_id.to_string(),
192            receipt_key,
193            fingerprint,
194            pending_daily,
195            daily_cap: limits.max_daily_value(),
196            today: now.date_naive(),
197            already_committed,
198        })
199    }
200
201    /// Release an authoritative explicit-reject reservation exactly once.
202    /// Unknown/timeout/malformed outcomes must not call this method.
203    pub fn release_batch_receipt(
204        &self,
205        key_id: &str,
206        receipt_id: &str,
207        contexts: &[CheckCtx],
208    ) -> Result<bool, LimitOutcome> {
209        let fingerprint = batch_fingerprint(contexts);
210        self.release_batch_receipt_internal(key_id, receipt_id, Some(&fingerprint))
211    }
212
213    /// Release the exact durable caller+intent receipt after an authoritative
214    /// asynchronous failure. The original request contexts are already bound
215    /// into the immutable receipt fingerprint and deltas at commit time.
216    pub fn release_batch_receipt_by_id(
217        &self,
218        key_id: &str,
219        receipt_id: &str,
220    ) -> Result<bool, LimitOutcome> {
221        self.release_batch_receipt_internal(key_id, receipt_id, None)
222    }
223
224    fn release_batch_receipt_internal(
225        &self,
226        key_id: &str,
227        receipt_id: &str,
228        expected_fingerprint: Option<&[u8; 32]>,
229    ) -> Result<bool, LimitOutcome> {
230        let receipt_key = format!("{}\0{}", key_id, receipt_id);
231        let mut receipts = self.daily_receipts.lock();
232        let Some(receipt) = receipts.get(&receipt_key).cloned() else {
233            return Ok(false);
234        };
235        if expected_fingerprint.is_some_and(|fingerprint| receipt.fingerprint != *fingerprint) {
236            return Err(LimitOutcome::ValueReject(
237                "daily quota receipt release used different order facts".into(),
238            ));
239        }
240        let counter = self.counters.get(key_id).ok_or_else(|| {
241            LimitOutcome::ValueReject("daily quota receipt has no counter state".into())
242        })?;
243        counter
244            .try_subtract_batch(&receipt.deltas, receipt.day)
245            .map_err(batch_daily_error)?;
246        receipts.remove(&receipt_key);
247        Ok(true)
248    }
249}