1use std::collections::HashMap;
16use std::collections::HashSet;
17use std::path::Path;
18use std::path::PathBuf;
19use std::sync::atomic::AtomicBool;
20use std::sync::atomic::AtomicI64;
21use std::sync::atomic::Ordering;
22use std::sync::Arc;
23use std::time::Duration;
24
25use cheetah_string::CheetahString;
26use dashmap::DashMap;
27use parking_lot::Mutex;
28use rocketmq_model::common::message::message_queue::MessageQueue;
29use rocketmq_protocol::protocol::RemotingDeserializable;
30use rocketmq_protocol::protocol::RemotingSerializable;
31use rocketmq_runtime::BudgetCapacity;
32use rocketmq_runtime::BudgetLimit;
33use rocketmq_runtime::BudgetedQueue;
34use rocketmq_runtime::FullPolicy;
35use rocketmq_runtime::QueuePushOutcome;
36use rocketmq_runtime::RateLimit;
37use rocketmq_runtime::ResourceBudget;
38use rocketmq_runtime::ScheduledTaskSnapshot;
39use serde::Serialize;
40use std::sync::LazyLock;
41use tokio::fs;
42use tokio::io::AsyncWriteExt;
43use tokio::sync::Mutex as TokioMutex;
44use tracing::error;
45use tracing::info;
46use tracing::warn;
47
48use crate::consumer::store::controllable_offset::ControllableOffset;
49use crate::consumer::store::offset_serialize::OffsetSerialize;
50use crate::consumer::store::offset_serialize_wrapper::OffsetSerializeWrapper;
51use crate::consumer::store::offset_store::OffsetStoreTrait;
52use crate::consumer::store::read_offset_type::ReadOffsetType;
53use crate::factory::mq_client_instance::MQClientInstance;
54use crate::runtime::schedule_client_fixed_delay_task_with_context;
55use crate::runtime::spawn_client_tracked_task_with_context;
56use crate::runtime::ClientRuntime;
57use crate::runtime::ClientScheduledTaskHandle;
58use crate::runtime::ClientTrackedTaskHandle;
59use rocketmq_runtime::ChildServiceContext;
60
61const PERSIST_TASK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
62const PERIODIC_PERSIST_INTERVAL: Duration = Duration::from_secs(5);
63
64static LOCAL_OFFSET_STORE_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
65 #[cfg(target_os = "windows")]
66 let home = std::env::var("USERPROFILE").map_or(PathBuf::from("C:\\tmp\\.rocketmq_offsets"), |home| {
67 PathBuf::from(home).join(".rocketmq_offsets")
68 });
69
70 #[cfg(not(target_os = "windows"))]
71 let home = std::env::var("HOME").map_or(PathBuf::from("/tmp/.rocketmq_offsets"), |home| {
72 PathBuf::from(home).join(".rocketmq_offsets")
73 });
74
75 std::env::var("rocketmq.client.localOffsetStoreDir").map_or(home, PathBuf::from)
76});
77
78enum PersistCommand {
79 PersistMQs(
80 HashSet<MessageQueue>,
81 Option<tokio::sync::oneshot::Sender<Result<(), std::io::Error>>>,
82 ),
83 PersistAll(Option<tokio::sync::oneshot::Sender<Result<(), std::io::Error>>>),
84 Shutdown,
85}
86
87impl PersistCommand {
88 fn retained_bytes(&self) -> usize {
89 let payload_bytes = match self {
90 Self::PersistMQs(queues, _) => queues.iter().fold(
91 queues
92 .capacity()
93 .saturating_mul(std::mem::size_of::<MessageQueue>() + 1),
94 |total, queue| {
95 total
96 .saturating_add(queue.topic().len())
97 .saturating_add(queue.broker_name().len())
98 },
99 ),
100 Self::PersistAll(_) | Self::Shutdown => 0,
101 };
102 std::mem::size_of::<Self>().saturating_add(payload_bytes)
103 }
104}
105
106pub struct LocalFileOffsetStore {
107 client_instance: Arc<MQClientInstance>,
108 group_name: CheetahString,
109 store_path: CheetahString,
110 offset_table: Arc<DashMap<MessageQueue, ControllableOffset>>,
111 dirty_flag: Arc<AtomicBool>,
112 persist_commands: BudgetedQueue<PersistCommand>,
113 persist_handle: Mutex<PersistTaskHandle>,
114}
115
116enum PersistTaskHandle {
117 Tokio {
118 command_handle: ClientTrackedTaskHandle,
119 scheduled_handle: Option<ClientScheduledTaskHandle>,
120 },
121 NotStarted,
122}
123
124impl PersistTaskHandle {
125 fn is_finished(&self) -> bool {
126 match self {
127 Self::Tokio {
128 command_handle,
129 scheduled_handle,
130 } => command_handle.is_finished() && scheduled_handle.as_ref().is_none_or(|handle| !handle.is_running()),
131 Self::NotStarted => true,
132 }
133 }
134
135 async fn shutdown(self, persist_commands: &BudgetedQueue<PersistCommand>, timeout: Duration) -> bool {
136 match self {
137 Self::Tokio {
138 command_handle,
139 scheduled_handle,
140 } => {
141 let started_at = std::time::Instant::now();
142 let mut healthy = true;
143 if let Some(scheduled_handle) = scheduled_handle {
144 let report = scheduled_handle.shutdown(timeout).await;
145 if !report.is_healthy() {
146 warn!(
147 report = %report.to_json(),
148 "local offset store periodic persist shutdown report is unhealthy"
149 );
150 healthy = false;
151 }
152 }
153
154 let command = PersistCommand::Shutdown;
155 let _ = persist_commands.try_push_control(command, std::mem::size_of::<PersistCommand>());
156 let remaining = timeout.checked_sub(started_at.elapsed()).unwrap_or(Duration::ZERO);
157 Self::shutdown_command_task(command_handle, remaining).await && healthy
158 }
159 Self::NotStarted => {
160 let command = PersistCommand::Shutdown;
161 let _ = persist_commands.try_push_control(command, std::mem::size_of::<PersistCommand>());
162 true
163 }
164 }
165 }
166
167 fn shutdown_now(self) {
168 if let Self::Tokio {
169 scheduled_handle: Some(scheduled_handle),
170 ..
171 } = self
172 {
173 let report = scheduled_handle.shutdown_now();
174 if !report.is_healthy() {
175 warn!(
176 report = %report.to_json(),
177 "local offset store periodic persist shutdown_now report is unhealthy"
178 );
179 }
180 }
181 }
182
183 async fn shutdown_command_task(handle: ClientTrackedTaskHandle, timeout: Duration) -> bool {
184 let report = handle.shutdown(timeout).await;
185 if !report.is_healthy() {
186 warn!(
187 report = %report.to_json(),
188 "local offset store persist task shutdown report is unhealthy"
189 );
190 }
191 report.is_healthy()
192 }
193
194 fn task_count(&self) -> usize {
195 match self {
196 Self::Tokio {
197 command_handle,
198 scheduled_handle,
199 } => {
200 command_handle.task_count()
201 + scheduled_handle
202 .as_ref()
203 .map(ClientScheduledTaskHandle::task_count)
204 .unwrap_or_default()
205 }
206 Self::NotStarted => 0,
207 }
208 }
209
210 fn schedule_snapshot(&self) -> Vec<ScheduledTaskSnapshot> {
211 match self {
212 Self::Tokio {
213 scheduled_handle: Some(scheduled_handle),
214 ..
215 } => scheduled_handle.schedule_snapshot(),
216 _ => Vec::new(),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Serialize)]
222pub struct LocalFileOffsetStoreLifecycleProbe {
223 pub task_count_before_shutdown: usize,
224 pub task_count_after_shutdown: usize,
225 pub scheduled_runs: u64,
226 pub scheduled_skips: u64,
227 pub scheduled_overlaps: u64,
228 pub scheduled_failures: u64,
229 pub persisted_offset_file: bool,
230 pub shutdown_elapsed_us: u128,
231 pub healthy: bool,
232}
233
234impl LocalFileOffsetStore {
235 pub fn new(
242 service_context: ChildServiceContext,
243 client_instance: Arc<MQClientInstance>,
244 group_name: CheetahString,
245 ) -> Self {
246 Self::try_new(service_context, client_instance, group_name)
247 .unwrap_or_else(|error| panic!("invalid local offset-store resource budget: {error}"))
248 }
249
250 pub fn try_new(
251 service_context: ChildServiceContext,
252 client_instance: Arc<MQClientInstance>,
253 group_name: CheetahString,
254 ) -> crate::ClientResult<Self> {
255 let store_path = LOCAL_OFFSET_STORE_DIR
256 .clone()
257 .join(client_instance.client_id.as_str())
258 .join(group_name.as_str())
259 .join("offsets.json")
260 .to_string_lossy()
261 .to_string();
262
263 let offset_table = Arc::new(DashMap::new());
264 let dirty_flag = Arc::new(AtomicBool::new(false));
265 let persist_commands = Self::build_persist_command_queue_with_parent(&client_instance.resource_budget())?;
266
267 let offset_table_clone = offset_table.clone();
269 let dirty_flag_clone = dirty_flag.clone();
270 let store_path_clone = store_path.clone();
271
272 let persist_handle = Self::spawn_background_persist_task_with_interval(
273 &service_context,
274 offset_table_clone,
275 dirty_flag_clone,
276 store_path_clone,
277 persist_commands.clone(),
278 PERIODIC_PERSIST_INTERVAL,
279 );
280
281 Ok(Self {
282 client_instance,
283 group_name,
284 store_path: CheetahString::from(store_path),
285 offset_table,
286 dirty_flag,
287 persist_commands,
288 persist_handle: Mutex::new(persist_handle),
289 })
290 }
291
292 fn build_persist_command_queue() -> crate::ClientResult<BudgetedQueue<PersistCommand>> {
293 Self::build_persist_command_queue_with_parent(&crate::runtime::standalone_client_resource_budget()?)
294 }
295
296 fn build_persist_command_queue_with_parent(
297 parent_budget: &ResourceBudget,
298 ) -> crate::ClientResult<BudgetedQueue<PersistCommand>> {
299 let queue_bytes = (parent_budget.limit().capacity.bytes / 64).max(1);
300 let control_bytes = std::mem::size_of::<PersistCommand>().min(queue_bytes);
301 let budget = parent_budget
302 .child(
303 "local-offset-persist-commands",
304 BudgetLimit::new(1_024, queue_bytes, FullPolicy::Reject)
305 .with_control_reserve(BudgetCapacity::new(1, control_bytes).with_rate(RateLimit::new(1, 1)))
306 .with_rate(RateLimit::new(1_024, 1_024))
307 .with_max_age(Duration::from_secs(30)),
308 )
309 .map_err(|error| {
310 crate::ClientError::config_invalid_source("client.localOffsetStore.commandQueue", true, error)
311 })?;
312 Ok(BudgetedQueue::new(budget))
313 }
314
315 fn spawn_background_persist_task(
316 service_context: &ChildServiceContext,
317 offset_table: Arc<DashMap<MessageQueue, ControllableOffset>>,
318 dirty_flag: Arc<AtomicBool>,
319 store_path: String,
320 persist_commands: BudgetedQueue<PersistCommand>,
321 ) -> PersistTaskHandle {
322 Self::spawn_background_persist_task_with_interval(
323 service_context,
324 offset_table,
325 dirty_flag,
326 store_path,
327 persist_commands,
328 PERIODIC_PERSIST_INTERVAL,
329 )
330 }
331
332 fn spawn_background_persist_task_with_interval(
333 service_context: &ChildServiceContext,
334 offset_table: Arc<DashMap<MessageQueue, ControllableOffset>>,
335 dirty_flag: Arc<AtomicBool>,
336 store_path: String,
337 persist_commands: BudgetedQueue<PersistCommand>,
338 persist_interval: Duration,
339 ) -> PersistTaskHandle {
340 let persist_lock = Arc::new(TokioMutex::new(()));
341 let command_offset_table = offset_table.clone();
342 let command_dirty_flag = dirty_flag.clone();
343 let command_store_path = store_path.clone();
344 let command_persist_lock = persist_lock.clone();
345 let task = async move {
346 Self::background_persist_task(
347 command_offset_table,
348 command_dirty_flag,
349 command_store_path,
350 persist_commands,
351 command_persist_lock,
352 )
353 .await;
354 };
355
356 let command_handle = match spawn_client_tracked_task_with_context(
357 service_context,
358 "rocketmq-client-local-offset-store",
359 Box::pin(task),
360 ) {
361 Ok(handle) => handle,
362 Err(error) => {
363 error!("Failed to spawn LocalFileOffsetStore background task: {}", error);
364 return PersistTaskHandle::NotStarted;
365 }
366 };
367
368 let scheduled_offset_table = offset_table;
369 let scheduled_dirty_flag = dirty_flag;
370 let scheduled_store_path = store_path;
371 let scheduled_handle = match schedule_client_fixed_delay_task_with_context(
372 service_context,
373 "rocketmq-client-local-offset-store-periodic",
374 persist_interval,
375 persist_interval,
376 move || {
377 let offset_table = scheduled_offset_table.clone();
378 let dirty_flag = scheduled_dirty_flag.clone();
379 let store_path = scheduled_store_path.clone();
380 let persist_lock = persist_lock.clone();
381 async move {
382 if dirty_flag.swap(false, Ordering::AcqRel) {
383 let _guard = persist_lock.lock().await;
384 if let Err(error) = Self::do_persist(&offset_table, &store_path, None).await {
385 error!("Background persist failed: {}", error);
386 dirty_flag.store(true, Ordering::Release);
387 }
388 }
389 }
390 },
391 ) {
392 Ok(handle) => Some(handle),
393 Err(error) => {
394 error!("Failed to spawn LocalFileOffsetStore periodic task: {}", error);
395 None
396 }
397 };
398
399 PersistTaskHandle::Tokio {
400 command_handle,
401 scheduled_handle,
402 }
403 }
404
405 pub async fn shutdown(&self) -> bool {
406 self.shutdown_with_timeout(PERSIST_TASK_SHUTDOWN_TIMEOUT).await
407 }
408
409 pub async fn shutdown_with_timeout(&self, timeout: Duration) -> bool {
410 let handle = {
411 let mut persist_handle = self.persist_handle.lock();
412 std::mem::replace(&mut *persist_handle, PersistTaskHandle::NotStarted)
413 };
414 handle.shutdown(&self.persist_commands, timeout).await
415 }
416
417 fn persist_task_count(&self) -> usize {
418 self.persist_handle.lock().task_count()
419 }
420
421 fn periodic_persist_snapshot(&self) -> Vec<ScheduledTaskSnapshot> {
422 self.persist_handle.lock().schedule_snapshot()
423 }
424
425 async fn read_local_offset(&self) -> crate::ClientResult<Option<OffsetSerializeWrapper>> {
426 let content = fs::read_to_string(self.store_path.as_str()).await.unwrap_or_default();
427
428 if content.is_empty() {
429 warn!(
430 "Offset store file not found or empty, trying backup: {}",
431 self.store_path
432 );
433 self.read_local_offset_bak().await
434 } else {
435 match OffsetSerialize::decode(content.as_bytes()) {
436 Ok(value) => Ok(Some(value.into())),
437 Err(e) => {
438 error!("Failed to deserialize local offset: {}, trying backup", e);
439 self.read_local_offset_bak().await
440 }
441 }
442 }
443 }
444
445 async fn read_local_offset_bak(&self) -> crate::ClientResult<Option<OffsetSerializeWrapper>> {
446 let bak_path = format!("{}.bak", self.store_path);
447 let content = fs::read_to_string(&bak_path).await.unwrap_or_default();
448
449 if content.is_empty() {
450 warn!("No backup file found, starting with empty offset table");
451 Ok(None)
452 } else {
453 match OffsetSerialize::decode(content.as_bytes()) {
454 Ok(value) => {
455 info!("Successfully loaded offset from backup file");
456 Ok(Some(value.into()))
457 }
458 Err(e) => Err(mq_client_err!(format!("Failed to parse backup offset file: {}", e))),
459 }
460 }
461 }
462
463 async fn atomic_write_to_file(store_path: &str, content: &str) -> Result<(), std::io::Error> {
464 let tmp_path = format!("{}.tmp", store_path);
465 let bak_path = format!("{}.bak", store_path);
466
467 let mut tmp_file = fs::File::create(&tmp_path).await?;
469 tmp_file.write_all(content.as_bytes()).await?;
470 tmp_file.sync_all().await?;
471 drop(tmp_file);
472
473 if fs::try_exists(store_path).await.unwrap_or(false) {
475 fs::rename(store_path, &bak_path).await.ok();
476 }
477
478 fs::rename(&tmp_path, store_path).await?;
480
481 if let Some(parent) = Path::new(store_path).parent() {
483 if let Ok(dir) = fs::File::open(parent).await {
484 dir.sync_all().await.ok();
485 }
486 }
487
488 Ok(())
489 }
490
491 async fn background_persist_task(
492 offset_table: Arc<DashMap<MessageQueue, ControllableOffset>>,
493 dirty_flag: Arc<AtomicBool>,
494 store_path: String,
495 commands: BudgetedQueue<PersistCommand>,
496 persist_lock: Arc<TokioMutex<()>>,
497 ) {
498 while let Some(command) = commands.recv_budgeted().await {
499 let (cmd, _permit, _) = command.into_parts();
500 match cmd {
501 PersistCommand::PersistMQs(mqs, reply) => {
502 let _guard = persist_lock.lock().await;
503 let result = Self::do_persist(&offset_table, &store_path, Some(mqs)).await;
504 if let Err(ref e) = result {
505 error!("Persist MQs failed: {}", e);
506 }
507 if let Some(tx) = reply {
508 tx.send(result).ok();
509 }
510 }
511 PersistCommand::PersistAll(reply) => {
512 let _guard = persist_lock.lock().await;
513 let result = Self::do_persist(&offset_table, &store_path, None).await;
514 if let Err(ref e) = result {
515 error!("Persist all failed: {}", e);
516 }
517 if let Some(tx) = reply {
518 tx.send(result).ok();
519 }
520 }
521 PersistCommand::Shutdown => {
522 if dirty_flag.load(Ordering::Acquire) {
524 let _guard = persist_lock.lock().await;
525 if let Err(e) = Self::do_persist(&offset_table, &store_path, None).await {
526 error!("Final persist on shutdown failed: {}", e);
527 }
528 }
529 break;
530 }
531 }
532 }
533 }
534
535 async fn do_persist(
536 offset_table: &DashMap<MessageQueue, ControllableOffset>,
537 store_path: &str,
538 mqs: Option<HashSet<MessageQueue>>,
539 ) -> Result<(), std::io::Error> {
540 let existing = fs::read_to_string(store_path).await.unwrap_or_default();
542
543 let mut wrapper = if !existing.is_empty() {
544 OffsetSerialize::decode(existing.as_bytes())
545 .map(|v: OffsetSerialize| OffsetSerializeWrapper::from(v))
546 .unwrap_or_default()
547 } else {
548 OffsetSerializeWrapper::default()
549 };
550
551 match mqs {
553 Some(ref set) => {
554 for mq in set {
555 if let Some(entry) = offset_table.get(mq) {
556 wrapper
557 .offset_table
558 .insert(mq.clone(), AtomicI64::new(entry.value().get_offset()));
559 }
560 }
561 }
562 None => {
563 for entry in offset_table.iter() {
564 wrapper
565 .offset_table
566 .insert(entry.key().clone(), AtomicI64::new(entry.value().get_offset()));
567 }
568 }
569 }
570
571 let content = OffsetSerialize::from(wrapper)
573 .serialize_json_pretty()
574 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
575
576 if content.is_empty() {
577 return Ok(());
578 }
579
580 Self::atomic_write_to_file(store_path, &content).await
582 }
583
584 async fn submit_persist_command(
585 &self,
586 command: PersistCommand,
587 receiver: tokio::sync::oneshot::Receiver<Result<(), std::io::Error>>,
588 operation: &'static str,
589 ) {
590 let retained_bytes = command.retained_bytes();
591 match self.persist_commands.try_push_data(command, retained_bytes) {
592 QueuePushOutcome::Enqueued | QueuePushOutcome::Coalesced { .. } | QueuePushOutcome::DroppedStale { .. } => {
593 Self::await_persist_result(receiver, operation).await
594 }
595 QueuePushOutcome::Rejected { item, rejection } => {
596 drop(item);
597 warn!(
598 operation,
599 ?rejection,
600 "local offset persist queue rejected a scoped command; retrying through the control reserve"
601 );
602
603 let (fallback_sender, fallback_receiver) = tokio::sync::oneshot::channel();
604 let fallback = PersistCommand::PersistAll(Some(fallback_sender));
605 let retained_bytes = fallback.retained_bytes();
606 match self.persist_commands.try_push_control(fallback, retained_bytes) {
607 QueuePushOutcome::Enqueued
608 | QueuePushOutcome::Coalesced { .. }
609 | QueuePushOutcome::DroppedStale { .. } => {
610 Self::await_persist_result(fallback_receiver, operation).await
611 }
612 QueuePushOutcome::Rejected { rejection, .. } => {
613 error!(
614 operation,
615 ?rejection,
616 "local offset persist control reserve is unavailable; the dirty offset remains eligible \
617 for periodic persistence"
618 );
619 }
620 }
621 }
622 }
623 }
624
625 async fn await_persist_result(
626 receiver: tokio::sync::oneshot::Receiver<Result<(), std::io::Error>>,
627 operation: &'static str,
628 ) {
629 match receiver.await {
630 Ok(Ok(())) => {}
631 Ok(Err(error)) => error!(operation, %error, "local offset persistence failed"),
632 Err(error) => error!(
633 operation,
634 %error,
635 "local offset persistence worker dropped the completion channel"
636 ),
637 }
638 }
639}
640
641impl OffsetStoreTrait for LocalFileOffsetStore {
642 async fn load(&self) -> crate::ClientResult<()> {
643 let offset_serialize_wrapper = self.read_local_offset().await?;
644 if let Some(offset_serialize_wrapper) = offset_serialize_wrapper {
645 let offset_table = offset_serialize_wrapper.offset_table;
646 self.offset_table.clear();
647 for (mq, offset) in offset_table {
648 let offset = offset.load(Ordering::Relaxed);
649 info!("load consumer's offset, {} {} {}", self.group_name, mq, offset);
650 self.offset_table.insert(mq, ControllableOffset::new(offset));
651 }
652 }
653 Ok(())
654 }
655
656 async fn update_offset(&self, mq: &MessageQueue, offset: i64, increase_only: bool) {
657 self.offset_table
658 .entry(mq.clone())
659 .and_modify(|v| {
660 if increase_only {
661 v.update(offset, true);
662 } else {
663 v.update_unconditionally(offset);
664 }
665 })
666 .or_insert_with(|| ControllableOffset::new(offset));
667
668 self.dirty_flag.store(true, Ordering::Release);
669 }
670
671 async fn update_and_freeze_offset(&self, mq: &MessageQueue, offset: i64) {
672 self.offset_table
673 .entry(mq.clone())
674 .and_modify(|v| v.update_and_freeze(offset))
675 .or_insert_with(|| ControllableOffset::new_frozen(offset));
676
677 self.dirty_flag.store(true, Ordering::Release);
678 }
679
680 async fn read_offset(&self, mq: &MessageQueue, type_: ReadOffsetType) -> i64 {
681 match type_ {
682 ReadOffsetType::ReadFromMemory => self
683 .offset_table
684 .get(mq)
685 .map(|entry| entry.value().get_offset())
686 .unwrap_or(-1),
687 ReadOffsetType::MemoryFirstThenStore => {
688 if let Some(offset) = self.offset_table.get(mq).map(|entry| entry.value().get_offset()) {
689 return offset;
690 }
691 match self.read_local_offset().await {
692 Ok(offset_serialize_wrapper) => {
693 if let Some(offset_serialize_wrapper) = offset_serialize_wrapper {
694 if let Some(offset) = offset_serialize_wrapper.offset_table.get(mq) {
695 let offset = offset.load(Ordering::Relaxed);
696 self.update_offset(mq, offset, false).await;
697 offset
698 } else {
699 -1
700 }
701 } else {
702 -1
703 }
704 }
705 Err(_) => -1,
706 }
707 }
708 ReadOffsetType::ReadFromStore => match self.read_local_offset().await {
709 Ok(offset_serialize_wrapper) => {
710 if let Some(offset_serialize_wrapper) = offset_serialize_wrapper {
711 if let Some(offset) = offset_serialize_wrapper.offset_table.get(mq) {
712 let offset = offset.load(Ordering::Relaxed);
713 self.update_offset(mq, offset, false).await;
714 offset
715 } else {
716 -1
717 }
718 } else {
719 -1
720 }
721 }
722 Err(_) => -1,
723 },
724 }
725 }
726
727 async fn persist_all(&self, mqs: &HashSet<MessageQueue>) {
728 if mqs.is_empty() {
729 return;
730 }
731
732 let (tx, rx) = tokio::sync::oneshot::channel();
734 let command = PersistCommand::PersistMQs(mqs.clone(), Some(tx));
735 self.submit_persist_command(command, rx, "persist_all").await;
736 }
737
738 async fn persist(&self, mq: &MessageQueue) {
739 let mut set = HashSet::new();
741 set.insert(mq.clone());
742
743 let (tx, rx) = tokio::sync::oneshot::channel();
744 let command = PersistCommand::PersistMQs(set, Some(tx));
745 self.submit_persist_command(command, rx, "persist").await;
746 }
747
748 async fn remove_offset(&self, mq: &MessageQueue) {
749 self.offset_table.remove(mq);
750 self.dirty_flag.store(true, Ordering::Release);
751 info!(
752 "remove unnecessary messageQueue offset. group={}, mq={}, offsetTableSize={}",
753 mq,
754 self.group_name,
755 self.offset_table.len()
756 );
757 }
758
759 async fn clone_offset_table(&self, topic: &str) -> HashMap<MessageQueue, i64> {
760 self.offset_table
761 .iter()
762 .filter(|entry| topic.is_empty() || entry.key().topic_str() == topic)
763 .map(|entry| (entry.key().clone(), entry.value().get_offset()))
764 .collect()
765 }
766
767 async fn update_consume_offset_to_broker(
768 &self,
769 _mq: &MessageQueue,
770 _offset: i64,
771 _is_oneway: bool,
772 ) -> crate::ClientResult<()> {
773 Ok(())
774 }
775}
776
777impl Drop for LocalFileOffsetStore {
778 fn drop(&mut self) {
779 let command = PersistCommand::Shutdown;
781 let _ = self
782 .persist_commands
783 .try_push_control(command, std::mem::size_of::<PersistCommand>());
784 let handle = std::mem::replace(self.persist_handle.get_mut(), PersistTaskHandle::NotStarted);
785 handle.shutdown_now();
786 }
789}
790
791#[doc(hidden)]
792pub async fn run_local_file_offset_store_lifecycle_probe(
793 client_runtime: Arc<ClientRuntime>,
794) -> LocalFileOffsetStoreLifecycleProbe {
795 use crate::base::client_config::ClientConfig;
796
797 let root = std::env::temp_dir().join(format!(
798 "rocketmq-rust-local-offset-store-probe-{}",
799 rocketmq_runtime::common::time_utils::current_millis()
800 ));
801 let store_path = root.join("offsets.json").to_string_lossy().to_string();
802 if let Some(parent) = Path::new(&store_path).parent() {
803 tokio::fs::create_dir_all(parent)
804 .await
805 .expect("local offset probe directory should be created");
806 }
807
808 let client_instance = MQClientInstance::new_arc(
809 ClientConfig::default(),
810 0,
811 format!("local-offset-store-probe-{}", std::process::id()),
812 None,
813 client_runtime.component("local-offset-store-probe"),
814 client_runtime.telemetry_handle().clone(),
815 client_runtime.pool().request_future_holder(),
816 );
817 let offset_table = Arc::new(DashMap::<MessageQueue, ControllableOffset>::new());
818 let dirty_flag = Arc::new(AtomicBool::new(false));
819 let persist_commands =
820 LocalFileOffsetStore::build_persist_command_queue().expect("local offset probe persist command queue");
821 let persist_handle = LocalFileOffsetStore::spawn_background_persist_task_with_interval(
822 &client_runtime.component("local-offset-persist-probe"),
823 offset_table.clone(),
824 dirty_flag.clone(),
825 store_path.clone(),
826 persist_commands.clone(),
827 Duration::from_millis(1),
828 );
829 let store = LocalFileOffsetStore {
830 client_instance,
831 group_name: CheetahString::from_static_str("local_offset_store_probe_group"),
832 store_path: CheetahString::from(store_path.clone()),
833 offset_table,
834 dirty_flag,
835 persist_commands,
836 persist_handle: Mutex::new(persist_handle),
837 };
838 let mq = MessageQueue::from_parts("local-offset-store-probe-topic", "broker-a", 0);
839 store.update_offset(&mq, 66, false).await;
840
841 let mut snapshots = store.periodic_persist_snapshot();
842 for _ in 0..100 {
843 if snapshots
844 .iter()
845 .any(|snapshot| snapshot.runs > 0 && snapshot.active_runs == 0)
846 && tokio::fs::try_exists(&store_path).await.unwrap_or(false)
847 {
848 break;
849 }
850 tokio::time::sleep(Duration::from_millis(1)).await;
851 snapshots = store.periodic_persist_snapshot();
852 }
853
854 let scheduled_runs = snapshots.iter().map(|snapshot| snapshot.runs).sum();
855 let scheduled_skips = snapshots.iter().map(|snapshot| snapshot.skips).sum();
856 let scheduled_overlaps = snapshots.iter().map(|snapshot| snapshot.overlaps).sum();
857 let scheduled_failures = snapshots.iter().map(|snapshot| snapshot.failures).sum();
858 let task_count_before_shutdown = store.persist_task_count();
859 let persisted_offset_file = tokio::fs::try_exists(&store_path).await.unwrap_or(false);
860 let shutdown_started_at = std::time::Instant::now();
861 let shutdown_healthy = store.shutdown().await;
862 let shutdown_elapsed_us = shutdown_started_at.elapsed().as_micros();
863 let task_count_after_shutdown = store.persist_task_count();
864 let healthy = shutdown_healthy
865 && scheduled_runs > 0
866 && scheduled_overlaps == 0
867 && scheduled_failures == 0
868 && persisted_offset_file
869 && task_count_before_shutdown > 0
870 && task_count_after_shutdown == 0;
871
872 let _ = tokio::fs::remove_file(&store_path).await;
873 let _ = tokio::fs::remove_file(format!("{store_path}.bak")).await;
874 let _ = tokio::fs::remove_file(format!("{store_path}.tmp")).await;
875 let _ = tokio::fs::remove_dir_all(root).await;
876
877 LocalFileOffsetStoreLifecycleProbe {
878 task_count_before_shutdown,
879 task_count_after_shutdown,
880 scheduled_runs,
881 scheduled_skips,
882 scheduled_overlaps,
883 scheduled_failures,
884 persisted_offset_file,
885 shutdown_elapsed_us,
886 healthy,
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use std::future::pending;
893 use std::path::Path;
894 use std::sync::atomic::AtomicBool;
895 use std::sync::atomic::Ordering;
896 use std::sync::Arc;
897
898 use cheetah_string::CheetahString;
899 use dashmap::DashMap;
900 use parking_lot::Mutex;
901 use rocketmq_model::common::message::message_queue::MessageQueue;
902 use rocketmq_protocol::protocol::RemotingDeserializable;
903 use rocketmq_protocol::protocol::RemotingSerializable;
904
905 use super::PersistTaskHandle;
906 use crate::base::client_config::ClientConfig;
907 use crate::consumer::store::controllable_offset::ControllableOffset;
908 use crate::consumer::store::local_file_offset_store::LocalFileOffsetStore;
909 use crate::consumer::store::offset_serialize::OffsetSerialize;
910 use crate::consumer::store::offset_store::OffsetStoreTrait;
911 use crate::consumer::store::read_offset_type::ReadOffsetType;
912 use crate::factory::mq_client_instance::MQClientInstance;
913 use crate::runtime::spawn_client_tracked_task_with_context;
914
915 struct DropFlag(Arc<AtomicBool>);
916
917 impl Drop for DropFlag {
918 fn drop(&mut self) {
919 self.0.store(true, Ordering::Release);
920 }
921 }
922
923 fn test_runtime() -> Arc<crate::runtime::ClientRuntime> {
924 crate::runtime::test_client_runtime("local-file-offset-store-test")
925 }
926
927 fn test_client_instance(client_id: impl Into<CheetahString>) -> Arc<MQClientInstance> {
928 let runtime = test_runtime();
929 MQClientInstance::new_arc(
930 ClientConfig::default(),
931 0,
932 client_id,
933 None,
934 runtime.component("client-instance"),
935 runtime.telemetry_handle().clone(),
936 runtime.pool().request_future_holder(),
937 )
938 }
939
940 #[test]
941 fn new_with_explicit_runtime_does_not_spawn_during_construction() {
942 let runtime = test_runtime();
943 let client_instance = test_client_instance("local-offset-store-construction-test");
944 let store = LocalFileOffsetStore::new(
945 runtime.component("construction"),
946 client_instance,
947 CheetahString::from_static_str("local_offset_store_no_runtime_group"),
948 );
949
950 drop(store);
951 std::thread::sleep(std::time::Duration::from_millis(20));
952 }
953
954 #[tokio::test]
955 async fn memory_first_then_store_reads_local_file_on_memory_miss_like_java() {
956 let client_id = format!("local-offset-store-read-fallback-test-{}", std::process::id());
957 let group = CheetahString::from(format!("local_offset_store_read_fallback_group_{}", std::process::id()));
958 let runtime = test_runtime();
959 let client_instance = test_client_instance(client_id);
960 let store_dir = std::env::current_dir()
961 .unwrap()
962 .join("target")
963 .join("tmp")
964 .join("local_offset_store_read_fallback");
965 let store_path = store_dir.join("offsets.json").to_string_lossy().to_string();
966 let offset_table = Arc::new(DashMap::<MessageQueue, ControllableOffset>::new());
967 let dirty_flag = Arc::new(AtomicBool::new(false));
968 let persist_commands = LocalFileOffsetStore::build_persist_command_queue().expect("persist command queue");
969 let persist_handle = LocalFileOffsetStore::spawn_background_persist_task(
970 &runtime.component("read-fallback-persist"),
971 offset_table.clone(),
972 dirty_flag.clone(),
973 store_path.clone(),
974 persist_commands.clone(),
975 );
976 let store = LocalFileOffsetStore {
977 client_instance,
978 group_name: group,
979 store_path: CheetahString::from(store_path),
980 offset_table,
981 dirty_flag,
982 persist_commands,
983 persist_handle: Mutex::new(persist_handle),
984 };
985 let mq = MessageQueue::from_parts("local-offset-store-topic", "broker-a", 0);
986
987 if let Some(parent) = Path::new(store.store_path.as_str()).parent() {
988 tokio::fs::create_dir_all(parent).await.unwrap();
989 }
990 store.update_offset(&mq, 42, false).await;
991 store.persist(&mq).await;
992 store.offset_table.clear();
993
994 let offset = store.read_offset(&mq, ReadOffsetType::MemoryFirstThenStore).await;
995 assert_eq!(42, offset);
996 assert_eq!(42, store.read_offset(&mq, ReadOffsetType::ReadFromMemory).await);
997
998 let store_path = store.store_path.to_string();
999 store.dirty_flag.store(false, Ordering::Release);
1000 drop(store);
1001 let _ = tokio::fs::remove_file(&store_path).await;
1002 let _ = tokio::fs::remove_file(format!("{store_path}.bak")).await;
1003 let _ = tokio::fs::remove_file(format!("{store_path}.tmp")).await;
1004 }
1005
1006 #[tokio::test]
1007 async fn shutdown_waits_for_final_persist() {
1008 let client_id = format!("local-offset-store-shutdown-test-{}", std::process::id());
1009 let group = CheetahString::from(format!("local_offset_store_shutdown_group_{}", std::process::id()));
1010 let runtime = test_runtime();
1011 let client_instance = test_client_instance(client_id);
1012 let store_dir = std::env::current_dir()
1013 .unwrap()
1014 .join("target")
1015 .join("tmp")
1016 .join("local_offset_store_shutdown");
1017 let store_path = store_dir.join("offsets.json").to_string_lossy().to_string();
1018 let offset_table = Arc::new(DashMap::<MessageQueue, ControllableOffset>::new());
1019 let dirty_flag = Arc::new(AtomicBool::new(false));
1020 let persist_commands = LocalFileOffsetStore::build_persist_command_queue().expect("persist command queue");
1021 let persist_handle = LocalFileOffsetStore::spawn_background_persist_task(
1022 &runtime.component("shutdown-persist"),
1023 offset_table.clone(),
1024 dirty_flag.clone(),
1025 store_path.clone(),
1026 persist_commands.clone(),
1027 );
1028 let store = LocalFileOffsetStore {
1029 client_instance,
1030 group_name: group,
1031 store_path: CheetahString::from(store_path.clone()),
1032 offset_table,
1033 dirty_flag,
1034 persist_commands,
1035 persist_handle: Mutex::new(persist_handle),
1036 };
1037 let mq = MessageQueue::from_parts("local-offset-store-shutdown-topic", "broker-a", 0);
1038
1039 if let Some(parent) = Path::new(&store_path).parent() {
1040 tokio::fs::create_dir_all(parent).await.unwrap();
1041 }
1042 store.update_offset(&mq, 88, false).await;
1043
1044 assert!(store.shutdown().await);
1045 assert!(store.shutdown().await, "repeated shutdown should remain healthy");
1046 assert!(store.persist_handle.lock().is_finished());
1047
1048 let content = tokio::fs::read(&store_path).await.unwrap();
1049 let persisted = OffsetSerialize::decode(&content).expect("shutdown should persist offsets");
1050 let mq_key = mq.serialize_json().expect("message queue should serialize");
1051 assert_eq!(persisted.offset_table.get(&mq_key).copied(), Some(88));
1052
1053 let _ = tokio::fs::remove_file(&store_path).await;
1054 let _ = tokio::fs::remove_file(format!("{store_path}.bak")).await;
1055 let _ = tokio::fs::remove_file(format!("{store_path}.tmp")).await;
1056 }
1057
1058 #[tokio::test]
1059 async fn persist_task_shutdown_aborts_after_timeout() {
1060 let dropped = Arc::new(AtomicBool::new(false));
1061 let dropped_in_task = dropped.clone();
1062 let handle = spawn_client_tracked_task_with_context(
1063 &crate::runtime::test_service_context("local-offset-store-timeout-test"),
1064 "rocketmq-client-local-offset-store-timeout-test",
1065 Box::pin(async move {
1066 let _drop_flag = DropFlag(dropped_in_task);
1067 pending::<()>().await;
1068 }),
1069 )
1070 .expect("timeout test command task should spawn");
1071 let handle = PersistTaskHandle::Tokio {
1072 command_handle: handle,
1073 scheduled_handle: None,
1074 };
1075 let persist_commands = LocalFileOffsetStore::build_persist_command_queue().expect("persist command queue");
1076
1077 assert!(
1078 !handle
1079 .shutdown(&persist_commands, std::time::Duration::from_millis(20))
1080 .await
1081 );
1082 assert!(dropped.load(Ordering::Acquire));
1083 }
1084
1085 #[test]
1086 fn overload_preserves_shutdown_reserve_in_offset_command_queue() {
1087 let command_bytes = std::mem::size_of::<super::PersistCommand>().max(1);
1088 let root = rocketmq_runtime::ResourceBudgetTree::new(
1089 "client-offset-overload",
1090 rocketmq_runtime::BudgetLimit::new(3, command_bytes * 3, rocketmq_runtime::FullPolicy::Reject)
1091 .with_control_reserve(rocketmq_runtime::BudgetCapacity::new(1, command_bytes)),
1092 )
1093 .expect("root budget");
1094 let budget = root
1095 .root()
1096 .child(
1097 "persist-commands",
1098 rocketmq_runtime::BudgetLimit::new(3, command_bytes * 3, rocketmq_runtime::FullPolicy::Reject)
1099 .with_control_reserve(rocketmq_runtime::BudgetCapacity::new(1, command_bytes))
1100 .with_rate(rocketmq_runtime::RateLimit::new(4, 4))
1101 .with_max_age(std::time::Duration::from_secs(30)),
1102 )
1103 .expect("queue budget");
1104 let queue = rocketmq_runtime::BudgetedQueue::new(budget);
1105
1106 for _ in 0..2 {
1107 let command = super::PersistCommand::PersistAll(None);
1108 assert!(matches!(
1109 queue.try_push_data(command, command_bytes),
1110 rocketmq_runtime::QueuePushOutcome::Enqueued
1111 ));
1112 }
1113 assert!(matches!(
1114 queue.try_push_data(super::PersistCommand::PersistAll(None), command_bytes),
1115 rocketmq_runtime::QueuePushOutcome::Rejected { .. }
1116 ));
1117 assert!(matches!(
1118 queue.try_push_control(super::PersistCommand::Shutdown, command_bytes),
1119 rocketmq_runtime::QueuePushOutcome::Enqueued
1120 ));
1121
1122 let snapshot = queue.snapshot();
1123 assert_eq!(snapshot.depth, 3);
1124 assert_eq!(snapshot.retained_bytes, command_bytes * 3);
1125 assert_eq!(snapshot.rejected_count, 1);
1126 }
1127
1128 #[tokio::test]
1129 async fn local_file_offset_store_lifecycle_probe_reports_clean_shutdown() {
1130 let probe = super::run_local_file_offset_store_lifecycle_probe(test_runtime()).await;
1131
1132 assert!(probe.healthy, "{probe:?}");
1133 assert!(probe.persisted_offset_file, "{probe:?}");
1134 assert_eq!(probe.task_count_after_shutdown, 0, "{probe:?}");
1135 assert_eq!(probe.scheduled_overlaps, 0, "{probe:?}");
1136 assert_eq!(probe.scheduled_failures, 0, "{probe:?}");
1137 }
1138}