Skip to main content

mito2/engine/
region_hook.rs

1// Copyright 2023 Greptime Team
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
15//! Region hook extension point for observing SST writes and manifest mutations.
16//!
17//! ## Design
18//!
19//! The [`RegionHook`] trait observes region activity through two categories of
20//! callbacks — manifest/file observation and region lifecycle:
21//!
22//! - [`on_sst_files_written`]: Fires when mito2 physically writes SST **data files**.
23//!   Provides per-file [`SstInfo`] + [`FileMeta`]; metadata richness varies by path
24//!   (see [`SstFileInfo`] and the coverage footnote).
25//!
26//! - [`on_manifest_updated`]: Fires after a manifest write is committed to the **live**
27//!   (normal) manifest directory. Writes to the staging directory (enter staging,
28//!   operations during staging, the intermediate apply-staging edit) are suppressed —
29//!   their effects are accumulated and delivered in a single notification when the
30//!   staged actions are promoted to the live manifest. Receives the full
31//!   [`RegionMetaActionList`] so consumers can inspect what changed (file additions/
32//!   removals, schema changes, truncation, partition expression changes, etc.).
33//!
34//! - [`on_region_opened`] / [`on_region_closed`] / [`on_region_dropped`] / [`on_region_files_removed`]:
35//!   Region **lifecycle** callbacks for open, close, logical drop, and physical file removal.
36//!   See [Region lifecycle](#region-lifecycle) below.
37//!
38//! Hook implementations are registered via the [`Plugins`](common_base::Plugins) system:
39//! ```ignore
40//! plugins.insert(Arc::new(MyHook) as RegionHookRef);
41//! ```
42//!
43//! ## Coverage
44//!
45//! Only manifest writes to the **normal** (live) manifest directory trigger
46//! `on_manifest_updated`. Writes to the staging manifest directory (operations
47//! that happen while the region is in staging mode) are intentionally suppressed:
48//! their effects are accumulated and delivered in a single notification when
49//! the staged actions are promoted to the live manifest via `exit_staging_on_success`.
50//!
51//! | Scenario                     | `on_sst_files_written` | `on_manifest_updated` |
52//! |------------------------------|:----------------------:|:---------------------:|
53//! | Flush (memtable → SST)       | ✅ Yes                 | ✅ Yes                |
54//! | Local compaction             | ✅ Yes                 | ✅ Yes                |
55//! | Remote compaction            | ✅ (compactor node) ¹     | ✅ (compactor node) ¹    |
56//! | RegionEdit / bulk ingestion  | ❌ (files pre-written)  | ✅ Yes                |
57//! | Copy region                  | ❌ (object-store copy)  | ✅ Yes                |
58//! | Apply staging (promote)      | ❌ (delegates to edit)  | ✅ Yes ²               |
59//! | Alter (schema change)        | ❌ (no SST files)       | ✅ Yes                |
60//! | Truncate                     | ❌ (removes files)      | ✅ Yes                |
61//! | Enter staging                | ❌ (no SST files)       | ❌ (staging dir)      |
62//! | Operations during staging    | N/A                    | ❌ (staging dir)      |
63//! | Async index build            | ❌ (index files only)   | ✅ Yes                |
64//!
65//! ¹ Remote compaction runs on a dedicated compactor node via `open_compaction_region()`;
66//!   pass plugins via `OpenCompactionRegionRequest` to enable hooks there. `sst_infos` is
67//!   `#[serde(skip)]` over the wire, so each [`SstInfo`] is rebuilt from [`FileMeta`] with
68//!   empty footer/index — see [`SstFileInfo`] for field-level detail.
69//! ² Apply staging fires `on_manifest_updated` once when `exit_staging_on_success` promotes
70//!   all staged manifest actions (including the SST file additions) into the live manifest.
71//!   The intermediate staging `RegionEdit` is written to the staging directory and does not
72//!   fire the hook — its file list is included in the promote notification.
73//!
74//! The following paths do **not** trigger any hook:
75//! - Follower region sync / catchup (manifest read-only; followers don't author changes)
76//! - GC / checkpoint / remap (internal bookkeeping, not logical state changes)
77//!
78//! An explicit region **drop** does fire lifecycle hooks — see
79//! [Region lifecycle](#region-lifecycle).
80//!
81//! ## Region lifecycle
82//!
83//! Beyond manifest/SST observation, the hook observes the high-level lifecycle of an
84//! active region:
85//!
86//! | Event | Method | When |
87//! |-------|--------|------|
88//! | Open | [`on_region_opened`] | A create or open request registers the region as active (the counterpart to close/drop). Does not fire for the compactor's transient regions or the catch-up reopen. |
89//! | Close | [`on_region_closed`] | A close request (or a close-after-flush) removes the region from the active set. Data files, manifest and WAL state are **preserved**; the region may be reopened. |
90//! | Logical drop | [`on_region_dropped`] | A drop request has been handled: the region leaves the active set and its WAL entries are marked obsolete. Data files are **not yet deleted**. |
91//! | Physical file removal | [`on_region_files_removed`] | The drop GC worker has deleted the region directory. Terminal file-lifecycle event. |
92//! | Global GC pass | [`on_region_gc`] | The datanode's global GC worker finished a GC pass for a region — both periodic GC for live regions and the global reclamation of dropped/repartitioned regions (`is_region_dropped`). Also fired by the offline cleanup path (`handle_offline_cleanup_request`, i.e. soft-drop PURGE) once the region directory is removed, with `is_region_dropped = true` and `full_file_listing = true`. |
93//!
94//! Notes:
95//! - `on_region_closed` / `on_region_dropped` run **inline in the region worker loop**,
96//!   so implementations must be fast (same contract as `on_manifest_updated`).
97//! - `on_region_opened` runs inline in the worker loop on the **create** path, but on the
98//!   **open** path it fires inside the spawned open task (`common_runtime::spawn_global`),
99//!   i.e. concurrently with the worker loop — after WAL replay, before the region is
100//!   registered and its open request is acknowledged. Implementations must still be fast
101//!   and must not assume worker-loop-thread affinity or strict ordering against concurrent
102//!   requests to other regions.
103//! - `on_region_files_removed` runs on the background drop GC task, outside the worker loop.
104//! - When global GC is enabled and a normal table region is dropped with `partial_drop`, its
105//!   directory is left for global reclamation and `on_region_files_removed` is **not** fired
106//!   by the drop worker (observe it via the global GC path instead).
107//! - The offline cleanup path (`handle_offline_cleanup_request`, reached by a soft-drop
108//!   `PURGE`) force-removes the region directory but has no `RegionMetadataRef` for the
109//!   offline region, so it cannot fire `on_region_files_removed`. It fires `on_region_gc`
110//!   instead — with `is_region_dropped = true` and `full_file_listing = true` — so
111//!   extensions can reclaim sidecar state for the now-removed region. A hook `Err` is
112//!   propagated so the caller retries the (idempotent) cleanup.
113//! - Logical file removal (compaction, region edit, truncate) is already observable via
114//!   [`on_manifest_updated`] (`Edit.files_to_remove` / `Truncate` action); only the drop
115//!   worker's physical directory deletion needs a dedicated file hook.
116//!
117//! ## Invocation points
118//!
119//! `on_sst_files_written` is invoked at the SST write site (flush task or compaction task),
120//! immediately after SST files are written but **before** the manifest is committed.
121//!
122//! `on_manifest_updated` is funneled through [`ManifestContext::update_locked`],
123//! the sole caller of the low-level [`RegionManifestManager::update`], which
124//! packages each successful write into a [`PendingManifestHook`]. The caller
125//! owns the write lock, drops it, and *then* fires the receipt — the hook must
126//! never run under the lock. [`ManifestContext::update_manifest`] is the common
127//! case: it acquires the lock, delegates to `update_locked`, and fires the
128//! receipt in one go. Multi-step sequences (staging-exit, role-state backfill)
129//! call `update_locked` directly under their own held guard.
130//!
131//! Non-logical writes (GC, staging bookkeeping) call the manager's own methods
132//! directly and intentionally do not fire the hook.
133//!
134//! ## Future work
135//!
136//! `on_region_files_removed` currently covers only the **drop** GC worker's physical
137//! directory removal. The global-GC reclamation path and the offline-cleanup path
138//! (soft-drop PURGE) are covered by `on_region_gc` instead. A broader per-file
139//! `on_files_removed` hook covering compaction removal and truncate is not yet implemented
140//! (though logical file removal is already observable via `on_manifest_updated`,
141//! and the global GC reclamation path is covered by `on_region_gc`).
142//! Role/leadership transitions (`on_region_role_changed`) are also not hooked.
143//!
144//! [`on_sst_files_written`]: RegionHook::on_sst_files_written
145//! [`on_manifest_updated`]: RegionHook::on_manifest_updated
146//! [`on_region_opened`]: RegionHook::on_region_opened
147//! [`on_region_closed`]: RegionHook::on_region_closed
148//! [`on_region_dropped`]: RegionHook::on_region_dropped
149//! [`on_region_files_removed`]: RegionHook::on_region_files_removed
150//! [`on_region_gc`]: RegionHook::on_region_gc
151//! [`RegionManifestManager::update`]: crate::manifest::manager::RegionManifestManager::update
152//! [`ManifestContext::update_locked`]: crate::region::ManifestContext::update_locked
153//! [`ManifestContext::update_manifest`]: crate::region::ManifestContext::update_manifest
154
155use std::fmt::Debug;
156use std::sync::Arc;
157
158use async_trait::async_trait;
159use store_api::ManifestVersion;
160use store_api::metadata::RegionMetadataRef;
161use store_api::storage::RegionId;
162
163use crate::access_layer::AccessLayerRef;
164use crate::error::Result;
165use crate::manifest::action::{RegionMetaActionList, RemovedFile};
166use crate::sst::file::FileMeta;
167use crate::sst::parquet::SstInfo;
168
169/// A deferred [`RegionHook::on_manifest_updated`] notification produced by a
170/// logical manifest write via [`ManifestContext::update_locked`](crate::region::ManifestContext::update_locked).
171///
172/// Must be [`fire`](Self::fire)d **after** the manifest write lock is released
173/// (the hook may read the manifest). `#[must_use]` so a forgotten receipt warns.
174///
175/// ## Staging suppression
176///
177/// When `is_staging` is `true`, [`fire`](Self::fire) is a no-op — the write went to
178/// the staging manifest directory. The hook only observes writes to the live (normal)
179/// manifest directory. Staging actions are accumulated and delivered in a single
180/// notification when `exit_staging_on_success` promotes them (`is_staging = false`).
181#[must_use = "the region hook must be fired after releasing the manifest write lock"]
182pub(crate) struct PendingManifestHook {
183    region_id: RegionId,
184    /// `None` when no hook is registered (fire becomes a no-op).
185    action_list: Option<RegionMetaActionList>,
186    version: ManifestVersion,
187    hook: Option<RegionHookRef>,
188    /// Whether the manifest write went to the staging directory.
189    /// When `true`, `fire()` is suppressed — the hook only observes live manifest writes.
190    is_staging: bool,
191}
192
193impl PendingManifestHook {
194    pub(crate) fn new(
195        region_id: RegionId,
196        action_list: Option<RegionMetaActionList>,
197        version: ManifestVersion,
198        hook: Option<RegionHookRef>,
199        is_staging: bool,
200    ) -> Self {
201        Self {
202            region_id,
203            action_list,
204            version,
205            hook,
206            is_staging,
207        }
208    }
209
210    /// The manifest version produced by the write.
211    pub(crate) fn version(&self) -> ManifestVersion {
212        self.version
213    }
214
215    /// Fires the hook if one is registered, **unless** the write went to the staging
216    /// manifest directory (`is_staging = true`). Safe to call unconditionally:
217    /// it is a no-op when no hook is registered or when the write is staging-only.
218    pub(crate) async fn fire(self) {
219        if self.is_staging {
220            return;
221        }
222        if let (Some(hook), Some(action_list)) = (self.hook, self.action_list) {
223            hook.on_manifest_updated(self.region_id, &action_list, self.version)
224                .await;
225        }
226    }
227
228    /// Merges two pending notifications into one so consumers observe a single
229    /// `on_manifest_updated` call covering all actions. The combined action list
230    /// keeps `self`'s actions followed by `other`'s, and the *later* manifest
231    /// version wins. Used when a sequence of writes (e.g. staging-exit followed
232    /// by metadata backfill) should notify the hook exactly once.
233    ///
234    /// `is_staging` is `true` only if **both** sides are staging; if either side
235    /// is a live write, the merged result is also live.
236    pub(crate) fn merge(self, other: PendingManifestHook) -> PendingManifestHook {
237        debug_assert_eq!(
238            self.region_id, other.region_id,
239            "Cannot merge pending hooks of different regions: {:?} and {:?}",
240            self.region_id, other.region_id
241        );
242        PendingManifestHook {
243            region_id: self.region_id,
244            action_list: match (self.action_list, other.action_list) {
245                (Some(mut a), Some(b)) => {
246                    a.actions.extend(b.actions);
247                    Some(a)
248                }
249                (a, None) => a,
250                (None, b) => b,
251            },
252            version: self.version.max(other.version),
253            hook: self.hook.or(other.hook),
254            is_staging: self.is_staging && other.is_staging,
255        }
256    }
257}
258
259/// Information about a single SST data file written during flush or compaction.
260///
261/// `file_meta` is always complete. `sst_info_ref` mirrors those scalars and adds the
262/// Parquet footer (`file_metadata`) and full `index_metadata` — but **only when mito2
263/// wrote the file in-process** (flush, local compaction). On remote compaction `SstInfo`
264/// is rebuilt from `FileMeta`, so both are empty; hooks needing column statistics must
265/// fetch the footer from object storage.
266pub struct SstFileInfo<'a> {
267    pub sst_info_ref: &'a SstInfo,
268    pub file_meta: &'a FileMeta,
269}
270
271/// Hook for observing region mutations in mito2.
272///
273/// Implementations can be registered via the `Plugins` system:
274/// ```ignore
275/// use std::sync::Arc;
276/// use common_base::Plugins;
277/// use mito2::engine::region_hook::{RegionHook, RegionHookRef};
278///
279/// plugins.insert(Arc::new(MyHook) as RegionHookRef);
280/// ```
281#[async_trait]
282pub trait RegionHook: Send + Sync + Debug {
283    /// Called after SST **data files** are physically written, before manifest commit.
284    ///
285    /// This fires only when mito2 itself writes SST files (flush and compaction).
286    /// It does **not** fire when SST files are pre-written externally (bulk ingestion,
287    /// copy region) or when only index files are written (async index build).
288    ///
289    /// # Metadata availability
290    /// See [`SstFileInfo`]: `file_meta` is always complete, but the [`SstInfo`] footer
291    /// and index output are empty on remote compaction. Hooks needing column statistics
292    /// (e.g. an Iceberg manifest) must fetch the footer from object storage.
293    async fn on_sst_files_written(
294        &self,
295        region_id: RegionId,
296        region_metadata: &RegionMetadataRef,
297        files: &[SstFileInfo<'_>],
298    ) {
299        let _ = (region_id, region_metadata, files);
300    }
301
302    /// Called after the region manifest is successfully committed to the **live**
303    /// (normal) manifest directory.
304    ///
305    /// Fires for: flush, compaction, region edit, copy region, alter, truncate,
306    /// async index build, and apply-staging promote. Does **not** fire for writes
307    /// to the staging manifest directory (enter staging, operations during staging,
308    /// the intermediate apply-staging edit) — those are suppressed because their
309    /// effects are accumulated and delivered in a single promote notification.
310    ///
311    /// Does **not** fire for:
312    /// - Manifest reads / follower sync (no write)
313    /// - GC / checkpoint (internal bookkeeping)
314    /// - Failed manifest updates
315    async fn on_manifest_updated(
316        &self,
317        region_id: RegionId,
318        action_list: &RegionMetaActionList,
319        manifest_version: ManifestVersion,
320    ) {
321        let _ = (region_id, action_list, manifest_version);
322    }
323
324    /// Called once a region **open** or **create** succeeds, but **before** the
325    /// region is registered in the engine's active set (`insert_region` runs
326    /// immediately afterwards).
327    ///
328    /// Fires once when a region becomes active via a create or open request —
329    /// the natural counterpart to [`on_region_closed`] / [`on_region_dropped`].
330    /// It does **not** fire for the compactor's transient compaction regions
331    /// (`open_compaction_region`), nor for the internal reopen performed during
332    /// follower catch-up / leadership promotion.
333    ///
334    /// On the **create** path it runs inline in the region worker loop; on the
335    /// **open** path it runs inside the spawned open task
336    /// (`common_runtime::spawn_global`), concurrently with the worker loop
337    /// (after WAL replay, before the region is registered/acknowledged).
338    /// Implementations must be fast and must **not** assume worker-loop-thread
339    /// affinity or strict ordering against concurrent requests to other regions.
340    ///
341    /// [`on_region_closed`]: RegionHook::on_region_closed
342    /// [`on_region_dropped`]: RegionHook::on_region_dropped
343    async fn on_region_opened(&self, region_id: RegionId, region_metadata: &RegionMetadataRef) {
344        let _ = (region_id, region_metadata);
345    }
346
347    /// Called after a region is **closed** via a close request.
348    ///
349    /// The region is removed from the engine's active set, but its data files,
350    /// manifest, and WAL state are **preserved**; the region may be reopened
351    /// later. Fires once per successful close, after the region's background
352    /// tasks (flush/compaction) have been stopped.
353    ///
354    /// Fires for a region of **any** role (leader or follower) that is closed.
355    /// Does **not** fire when a region is dropped (see [`on_region_dropped`]).
356    ///
357    /// Runs inline in the region worker loop; implementations should be fast.
358    ///
359    /// [`on_region_dropped`]: RegionHook::on_region_dropped
360    async fn on_region_closed(&self, region_id: RegionId, region_metadata: &RegionMetadataRef) {
361        let _ = (region_id, region_metadata);
362    }
363
364    /// Called after a region is **logically dropped** (a drop request has been
365    /// handled).
366    ///
367    /// The region is removed from the active set and its WAL entries are marked
368    /// obsolete. Its data files are **not yet deleted** — they are scheduled for
369    /// asynchronous removal by the GC worker. Observe physical deletion via
370    /// [`on_region_files_removed`].
371    ///
372    /// Runs inline in the region worker loop; implementations should be fast.
373    ///
374    /// [`on_region_files_removed`]: RegionHook::on_region_files_removed
375    async fn on_region_dropped(&self, region_id: RegionId, region_metadata: &RegionMetadataRef) {
376        let _ = (region_id, region_metadata);
377    }
378
379    /// Called after a dropped region's data files are **physically removed** by
380    /// the drop GC worker (the region directory has been deleted).
381    ///
382    /// This is the terminal event in a region's file lifecycle; no further
383    /// callbacks fire for this region id afterwards. Fires only when the drop
384    /// worker itself deletes the directory. When global GC is enabled and the
385    /// region is a normal table region dropped with `partial_drop`, the
386    /// directory is left for global reclamation and this hook is **not** fired
387    /// by the drop worker.
388    ///
389    /// Runs on a background task, outside the region worker loop.
390    async fn on_region_files_removed(
391        &self,
392        region_id: RegionId,
393        region_metadata: &RegionMetadataRef,
394    ) {
395        let _ = (region_id, region_metadata);
396    }
397
398    /// Called after the datanode's global GC worker (`LocalGcWorker`) finishes a
399    /// GC pass for a region — live regions (periodic GC) or dropped/repartitioned
400    /// regions (the global reclamation path). Also fired by the offline cleanup
401    /// path (`handle_offline_cleanup_request`, i.e. soft-drop PURGE) once the
402    /// region directory has been removed. Lets extensions with sidecar files
403    /// outside mito2's region dir clean up residual files.
404    ///
405    /// Always scoped to [`RegionGcInfo::removed_files`]: clean only sidecar
406    /// artifacts for the files mito deleted this pass. On a
407    /// [`RegionGcInfo::full_file_listing`] pass you may also reconcile
408    /// sidecar-only orphans (e.g. detect a fully-reaped region by cross-checking
409    /// the region dir against your own manifest). This callback never authorizes
410    /// blind whole-directory removal — derive "fully reaped" from your own state
411    /// so a stale mito snapshot can't cause accidental deletion.
412    /// [`RegionGcInfo::is_region_dropped`] is context, not authorization.
413    ///
414    /// # Retry
415    ///
416    /// Returning `Err` keeps the region un-acknowledged (`need_retry_regions`)
417    /// for a future replay. **Dropped/repartitioned**: guaranteed — metasrv keeps
418    /// the `table_repart` tombstone until `Ok`, so full-listing passes continue
419    /// until cleanup finishes. **Live**: best-effort — not expedited through
420    /// candidate selection, and a later full-listing pass may not reconstruct the
421    /// same `removed_files`; treat live cleanup as opportunistic.
422    ///
423    /// `region_metadata` is `None` for dropped regions; use `region_id` +
424    /// `access_layer`. Idempotent.
425    ///
426    /// # Execution context
427    ///
428    /// On the global-GC path this runs on the background GC task, outside the
429    /// region worker loop. The offline cleanup path
430    /// (`handle_offline_cleanup_request`, reached by soft-drop PURGE) instead
431    /// runs it **inline in the region worker loop** and propagates `Err` so the
432    /// caller retries the (idempotent) cleanup. So — like `on_region_closed` /
433    /// `on_region_dropped` — implementations **must be fast and must not block
434    /// indefinitely** while awaited there: while the callback is pending, every
435    /// subsequent DDL request for every region on that worker is blocked.
436    /// Extension authors who wrote their hook against the "background GC task"
437    /// contract must account for this second, latency-sensitive trigger.
438    ///
439    /// The offline cleanup path fires this callback once the region directory is
440    /// confirmed **absent** — including no-op purges where nothing was actually
441    /// removed this call (a retry after the directory was already gone, or a
442    /// region that never had files on this datanode, since
443    /// `remove_region_dir_for_full_drop` returns `Ok` for an absent/empty
444    /// prefix). `RegionGcInfo::removed_files` is empty in that case; do **not**
445    /// interpret the call as "files were deleted in this call". This is
446    /// asymmetric with the GC path, which fires only when files were deleted or
447    /// a full listing was performed.
448    async fn on_region_gc(
449        &self,
450        region_id: RegionId,
451        region_metadata: Option<&RegionMetadataRef>,
452        access_layer: &AccessLayerRef,
453        info: &RegionGcInfo<'_>,
454    ) -> Result<()> {
455        let _ = (region_id, region_metadata, access_layer, info);
456        Ok(())
457    }
458}
459
460/// What mito2's GC pass deleted for a region, handed to
461/// [`RegionHook::on_region_gc`].
462pub struct RegionGcInfo<'a> {
463    /// Files mito2 physically deleted this pass. Cleanup must be scoped to these
464    /// (plus sidecar-only orphans you can identify on a [`Self::full_file_listing`]
465    /// pass).
466    pub removed_files: &'a [RemovedFile],
467    /// `true` when the region is dropped/absent (e.g. a repartitioned source).
468    /// Context only — not authorization. `region_metadata` is `None` when `true`.
469    pub is_region_dropped: bool,
470    /// Whether this pass did a full object-store listing.
471    pub full_file_listing: bool,
472}
473
474pub type RegionHookRef = Arc<dyn RegionHook>;