Skip to main content

rocketmq_client_rust/consumer/store/
local_file_offset_store.rs

1// Copyright 2023 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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    /// Creates a local offset store with a process-derived command budget.
236    ///
237    /// # Panics
238    ///
239    /// Panics when the process memory limit is unavailable. Production
240    /// composition should prefer [`Self::try_new`].
241    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        // Clone for background task
268        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        // 1. Write to temporary file and fsync
468        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        // 2. Backup existing file if it exists
474        if fs::try_exists(store_path).await.unwrap_or(false) {
475            fs::rename(store_path, &bak_path).await.ok();
476        }
477
478        // 3. Atomic rename (POSIX guarantees atomicity)
479        fs::rename(&tmp_path, store_path).await?;
480
481        // 4. Fsync the directory to ensure rename is durable
482        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                    // Final persist before shutdown
523                    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        // Read existing offsets
541        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        // Collect offsets to persist
552        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        // Serialize
572        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        // Use atomic write function
581        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        // Send persist command and wait for completion to maintain original semantics
733        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        // Send persist command and wait for completion to maintain original semantics
740        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        // Send shutdown command to background task
780        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        // Note: We can't await the command worker in Drop (it's not async).
787        // The worker receives Shutdown and performs the final persist best-effort.
788    }
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}