futu_auth/limits/runtime/
batch.rs1use super::*;
6use chrono::{DateTime, Utc};
7use sha2::{Digest, Sha256};
8use std::collections::BTreeMap;
9
10impl BatchLimitGuard<'_> {
11 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 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 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(¤cy).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 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 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}