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>;