be/src/io/cache/async_cache_write_manager.h
Line | Count | Source |
1 | | // Licensed to the Apache Software Foundation (ASF) under one |
2 | | // or more contributor license agreements. See the NOTICE file |
3 | | // distributed with this work for additional information |
4 | | // regarding copyright ownership. The ASF licenses this file |
5 | | // to you under the Apache License, Version 2.0 (the |
6 | | // "License"); you may not use this file except in compliance |
7 | | // with the License. You may obtain a copy of the License at |
8 | | // |
9 | | // http://www.apache.org/licenses/LICENSE-2.0 |
10 | | // |
11 | | // Unless required by applicable law or agreed to in writing, |
12 | | // software distributed under the License is distributed on an |
13 | | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
14 | | // KIND, either express or implied. See the License for the |
15 | | // specific language governing permissions and limitations |
16 | | // under the License. |
17 | | |
18 | | #pragma once |
19 | | |
20 | | #include <atomic> |
21 | | #include <condition_variable> |
22 | | #include <cstddef> |
23 | | #include <cstdint> |
24 | | #include <deque> |
25 | | #include <functional> |
26 | | #include <memory> |
27 | | #include <mutex> |
28 | | #include <vector> |
29 | | |
30 | | #include "common/atomic_shared_ptr.h" |
31 | | #include "common/status.h" |
32 | | #include "io/cache/file_cache_common.h" |
33 | | #include "runtime/memory/mem_tracker_limiter.h" |
34 | | #include "util/slice.h" |
35 | | #include "util/threadpool.h" |
36 | | |
37 | | namespace doris::io { |
38 | | |
39 | | class BlockFileCache; |
40 | | class AsyncCacheWriteEpochRegistry; |
41 | | class InflightWriteBufferIndex; |
42 | | |
43 | | /// Cache admission attributes captured on the query thread and replayed by a write worker. |
44 | | struct CacheAdmissionContext { |
45 | | /// Query identity used by per-query cache admission and accounting. |
46 | | TUniqueId query_id; |
47 | | FileCacheType cache_type {FileCacheType::NORMAL}; |
48 | | int64_t expiration_time {0}; |
49 | | int64_t tablet_id {0}; |
50 | | bool is_warmup {false}; |
51 | | |
52 | | /// Capture only the cache-admission fields that remain valid after the query thread returns. |
53 | | static CacheAdmissionContext from_cache_context(const CacheContext& context, int64_t tablet_id); |
54 | | |
55 | | /// Recreate the worker-local cache context and attach its temporary statistics sink. |
56 | | CacheContext to_cache_context(ReadStatistics* stats) const; |
57 | | }; |
58 | | |
59 | | /// Reference-counted payload whose allocation is charged to the async-write memory tracker. |
60 | | class AsyncCacheWriteBuffer { |
61 | | public: |
62 | | ~AsyncCacheWriteBuffer(); |
63 | | |
64 | 1.67k | char* data() { return _data; } |
65 | 0 | const char* data() const { return _data; } |
66 | 2.50k | size_t size() const { return _size; } |
67 | | |
68 | | private: |
69 | | friend class AsyncCacheWriteManager; |
70 | | |
71 | | AsyncCacheWriteBuffer(size_t size, std::shared_ptr<MemTrackerLimiter> tracker); |
72 | | |
73 | | char* _data = nullptr; |
74 | | size_t _size = 0; |
75 | | std::shared_ptr<MemTrackerLimiter> _tracker; |
76 | | }; |
77 | | |
78 | | using AsyncCacheWriteBufferPtr = std::shared_ptr<AsyncCacheWriteBuffer>; |
79 | | |
80 | | /// One live per-file write generation. All async read plans and writes for the same cache key share |
81 | | /// the current token. A key-scoped remove invalidates that token and lets later reads capture a new |
82 | | /// generation without retaining an epoch entry for every historical cache key. |
83 | | class AsyncCacheWriteEpochToken { |
84 | | public: |
85 | | ~AsyncCacheWriteEpochToken(); |
86 | | |
87 | 8 | uint64_t generation() const { return _generation; } |
88 | 1.99k | bool is_valid() const { return _valid.load(std::memory_order_acquire); } |
89 | | |
90 | | private: |
91 | | friend class AsyncCacheWriteEpochRegistry; |
92 | | |
93 | | AsyncCacheWriteEpochToken(const UInt128Wrapper& cache_hash, uint64_t generation, |
94 | | std::weak_ptr<AsyncCacheWriteEpochRegistry> registry); |
95 | | |
96 | | UInt128Wrapper _cache_hash; |
97 | | uint64_t _generation {0}; |
98 | | std::weak_ptr<AsyncCacheWriteEpochRegistry> _registry; |
99 | | std::atomic<bool> _valid {true}; |
100 | | }; |
101 | | |
102 | | /// Composite persistence fence shared by an async read plan and its derived write tasks. |
103 | | /// `cache_epoch` invalidates all older writes on one cache disk during a full-cache clear, while |
104 | | /// `key_token` lets a single-file removal invalidate only older writes for that cache key. The |
105 | | /// epoch does not govern inflight reads because a cache hash identifies immutable file content. |
106 | | struct AsyncCacheWriteEpoch { |
107 | | uint64_t cache_epoch {0}; |
108 | | std::shared_ptr<AsyncCacheWriteEpochToken> key_token; |
109 | | }; |
110 | | |
111 | | enum class AsyncCacheWriteBlockSubmitResult : uint8_t { |
112 | | /// The manager accepted ownership of a complete block buffer. |
113 | | SUBMITTED, |
114 | | /// The cache invalidation fence changed before submission. |
115 | | STALE_EPOCH, |
116 | | /// The copy-based submission could not allocate its tracked block buffer. |
117 | | BUFFER_ALLOCATION_FAILED, |
118 | | /// The same cache block already has a buffer indexed by an accepted write task. |
119 | | ALREADY_INFLIGHT, |
120 | | /// The manager is unavailable or its admission policy found no queue capacity. |
121 | | REJECTED, |
122 | | }; |
123 | | |
124 | | enum class AsyncCacheWriteAdmissionMode : uint8_t { |
125 | | /// Normal work may replace the oldest queued task when pending capacity is full. |
126 | | ALLOW_OLDEST_QUEUED_EVICTION, |
127 | | /// Lower-priority work is accepted only from currently unused pending capacity. |
128 | | REQUIRE_SPARE_CAPACITY, |
129 | | }; |
130 | | |
131 | | /// Transient source bytes and immutable metadata for one complete cache-block submission. |
132 | | /// `data` is copied before try_submit_block() returns. `buffer_size` is the fixed cache block |
133 | | /// allocation size; only the physical EOF block may provide a shorter data slice. |
134 | | struct AsyncCacheWriteBlockRequest { |
135 | | UInt128Wrapper cache_hash; |
136 | | size_t file_offset {0}; |
137 | | Slice data; |
138 | | size_t buffer_size {0}; |
139 | | CacheAdmissionContext admission_ctx; |
140 | | AsyncCacheWriteEpoch write_epoch; |
141 | | /// Optional index whose lifetime must cover every accepted write task. |
142 | | InflightWriteBufferIndex* inflight_index {nullptr}; |
143 | | }; |
144 | | |
145 | | /// Owned complete cache-block payload. The buffer must come from allocate_tracked_buffer() on the |
146 | | /// target manager and is transferred without another data copy. |
147 | | struct AsyncCacheWriteOwnedBlockRequest { |
148 | | UInt128Wrapper cache_hash; |
149 | | size_t file_offset {0}; |
150 | | size_t write_size {0}; |
151 | | AsyncCacheWriteBufferPtr buffer; |
152 | | CacheAdmissionContext admission_ctx; |
153 | | AsyncCacheWriteEpoch write_epoch; |
154 | | AsyncCacheWriteAdmissionMode admission_mode { |
155 | | AsyncCacheWriteAdmissionMode::ALLOW_OLDEST_QUEUED_EVICTION}; |
156 | | /// Optional index whose lifetime must cover every accepted write task. |
157 | | InflightWriteBufferIndex* inflight_index {nullptr}; |
158 | | }; |
159 | | |
160 | | /// One cache-block write. The sole production submitter allocates every `buffer` with exactly |
161 | | /// `file_cache_each_block_size` bytes. `write_size` is the valid prefix starting at `file_offset`; |
162 | | /// only the physical EOF block may use less than the full buffer. `write_epoch` prevents a worker |
163 | | /// from resurrecting data after cache invalidation. |
164 | | struct AsyncCacheWriteTask { |
165 | | using Finalizer = std::function<void(const AsyncCacheWriteTask&)>; |
166 | | |
167 | | UInt128Wrapper cache_hash; |
168 | | size_t file_offset {0}; |
169 | | size_t write_size {0}; |
170 | | AsyncCacheWriteBufferPtr buffer; |
171 | | CacheAdmissionContext admission_ctx; |
172 | | int64_t submit_ts_us {0}; |
173 | | AsyncCacheWriteEpoch write_epoch; |
174 | | Finalizer on_finalized; |
175 | | |
176 | | /// Assert the task contract at the manager boundary. |
177 | | void validate() const; |
178 | | |
179 | | /// Return the full allocation capacity charged to pending-byte accounting. |
180 | | size_t buffer_size() const; |
181 | | |
182 | | /// Run the optional owner cleanup after this task reaches a terminal state. |
183 | | void finalize() const; |
184 | | }; |
185 | | |
186 | | /// Complete per-cache-disk worker and memory settings. The manager receives this value |
187 | | /// explicitly at construction and through update_options(); it never reads global config. |
188 | | struct AsyncCacheWriteManagerOptions { |
189 | | size_t worker_count {1}; |
190 | | // Accepted queued+active buffer capacity. With fixed block-size buffers, any remainder smaller |
191 | | // than one block is intentionally unusable. |
192 | | size_t max_pending_bytes {1}; |
193 | | }; |
194 | | |
195 | | /// Resolve the configured BE-wide pending-byte ownership limit. A positive value is used |
196 | | /// unchanged; -1 selects max(1 GiB, 1% of `be_mem_limit`). Config validation rejects every other |
197 | | /// value. |
198 | | Status resolve_async_file_cache_write_max_pending_bytes(int64_t configured_bytes, |
199 | | int64_t be_mem_limit, |
200 | | size_t* resolved_bytes); |
201 | | |
202 | | /// Owns the bounded async-write queue and workers for one BlockFileCache (one cache disk). |
203 | | /// |
204 | | /// The referenced cache must outlive this manager. Shutdown stops new producers, waits registered |
205 | | /// producers, and drains all accepted tasks before worker resources are released. |
206 | | class AsyncCacheWriteManager { |
207 | | public: |
208 | | /// @param cache Non-owning target cache; it must outlive this manager. |
209 | | /// @param options Initial worker and pending-memory limits. |
210 | | AsyncCacheWriteManager(BlockFileCache* cache, AsyncCacheWriteManagerOptions options); |
211 | | ~AsyncCacheWriteManager(); |
212 | | |
213 | | /// Create the worker pool and schedule the configured long-running workers. Idempotent. |
214 | | Status start(); |
215 | | |
216 | | /// Admit `task` into the memory-bounded FIFO without waiting for disk I/O. Because all tasks |
217 | | /// have one fixed cache-block buffer capacity, normal admission to a full queue displaces |
218 | | /// exactly one oldest queued task. REQUIRE_SPARE_CAPACITY instead rejects a full-queue |
219 | | /// submission so lower-priority work cannot displace normal writes. Active tasks are never |
220 | | /// displaced. After a runtime limit decrease, normal submissions continue to replace the |
221 | | /// oldest queued task without increasing pending bytes, even while existing pending bytes |
222 | | /// exceed the new limit. This call can briefly wait for the queue mutex and finalizes a |
223 | | /// displaced task before returning. |
224 | | /// @return true if ownership was transferred to the queue; false when workers have not been |
225 | | /// started, during shutdown, or on backpressure. A rejected task's finalization callback is |
226 | | /// not invoked. |
227 | | bool try_submit(AsyncCacheWriteTask task, |
228 | | AsyncCacheWriteAdmissionMode admission_mode = |
229 | | AsyncCacheWriteAdmissionMode::ALLOW_OLDEST_QUEUED_EVICTION); |
230 | | |
231 | | /// Copy and submit one complete cache block through the same epoch, inflight-deduplication, |
232 | | /// memory-accounting, and queue path used by remote cache-miss reads. |
233 | | /// @return ALREADY_INFLIGHT when another accepted task already owns this block; in that case |
234 | | /// the request data is not copied. |
235 | | AsyncCacheWriteBlockSubmitResult try_submit_block(AsyncCacheWriteBlockRequest request); |
236 | | |
237 | | /// Submit an already owned complete cache block without copying its payload. This is the |
238 | | /// handoff used after background hole filling completes a tracked block buffer. |
239 | | /// The manager retains the buffer after this call only when SUBMITTED is returned. |
240 | | AsyncCacheWriteBlockSubmitResult try_submit_owned_block( |
241 | | AsyncCacheWriteOwnedBlockRequest request); |
242 | | |
243 | | /// Return whether a fixed-size task fits in currently unused pending capacity. The result is a |
244 | | /// point-in-time scheduling hint: a concurrent foreground submission may consume the capacity |
245 | | /// before the caller hands off its task. |
246 | | bool can_accept_without_eviction(size_t buffer_size) const; |
247 | | |
248 | | /// Return how many fixed-size tasks fit in the same point-in-time spare capacity. Background |
249 | | /// schedulers use this to avoid launching multiple remote reads against one available slot. |
250 | | size_t available_slots_without_eviction(size_t buffer_size) const; |
251 | | |
252 | | /// Return whether the manager currently accepts submissions. This is a point-in-time hint; |
253 | | /// submission methods still make the race-safe final decision. |
254 | | bool accepting() const; |
255 | | |
256 | | /// Check inflight buffers first, then probe one aligned cache block. Skip writeback if the |
257 | | /// block is already cached or being filled. Each lookup releases its lock before the next; |
258 | | /// this call must run outside the hole-fill queue mutex because probe may load cache metadata. |
259 | | bool should_skip_block_writeback(const UInt128Wrapper& cache_hash, size_t offset, size_t size, |
260 | | const CacheAdmissionContext& admission_ctx, |
261 | | InflightWriteBufferIndex* inflight_index) const; |
262 | | |
263 | | /// Allocate `size` payload bytes charged to the manager tracker and return them in `buffer`. |
264 | | Status allocate_tracked_buffer(size_t size, AsyncCacheWriteBufferPtr* buffer); |
265 | | |
266 | | /// Capture the disk-wide epoch and current live generation for `cache_hash`. |
267 | | AsyncCacheWriteEpoch current_write_epoch(const UInt128Wrapper& cache_hash); |
268 | | |
269 | | /// Return the current disk-wide epoch used by cache-clear operations. |
270 | 3.49k | uint64_t current_cache_epoch() const { return _cache_epoch.load(std::memory_order_acquire); } |
271 | | |
272 | | /// Test whether both levels of `epoch` still accept writes. |
273 | | bool is_current_write_epoch(const AsyncCacheWriteEpoch& epoch) const; |
274 | | |
275 | | /// Test the epoch and record one stale-drop metric when it is no longer current. |
276 | | bool check_write_epoch(const AsyncCacheWriteEpoch& epoch); |
277 | | |
278 | | /// Invalidate only queued/inflight work captured for `cache_hash` before this call. |
279 | | void invalidate_pending_writes(const UInt128Wrapper& cache_hash); |
280 | | |
281 | | /// Advance the disk-wide epoch so every previously captured write becomes stale. |
282 | | /// @return The newly active disk-wide epoch. |
283 | | uint64_t invalidate_all_pending_writes(); |
284 | | |
285 | | /// Return cache keys whose current valid generation is retained by at least one plan or task. |
286 | | /// Invalidated generations are excluded even while stale tasks finish releasing them. |
287 | | size_t active_write_epoch_key_count() const; |
288 | | |
289 | | /// Resize the number of active workers. A shrink waits only for retiring worker loops. |
290 | | /// @param worker_count Positive target worker count for this cache disk. |
291 | | Status resize_workers(size_t worker_count); |
292 | | |
293 | | /// Replace all mutable manager settings with one coherent snapshot. Configuration adapters |
294 | | /// call this method explicitly; the manager itself has no dependency on global config. |
295 | | /// @param options Complete validated settings, including the desired worker count. |
296 | | /// @return OK after the new snapshot is active; InvalidArgument for invalid limits, or a |
297 | | /// worker-resize error when the requested concurrency cannot be applied. |
298 | | Status update_options(const AsyncCacheWriteManagerOptions& options); |
299 | | |
300 | | /// Return the currently active settings as a value snapshot. |
301 | | AsyncCacheWriteManagerOptions options() const; |
302 | | |
303 | | /// Stop submissions, drain all accepted tasks, and join worker loops. Idempotent. |
304 | | void shutdown(); |
305 | | |
306 | | /// Return accepted tasks that have not yet completed finalization. |
307 | 174 | size_t pending_count() const { return _pending_count.load(std::memory_order_relaxed); } |
308 | | |
309 | | /// Return buffer-capacity bytes owned by queued and active tasks. |
310 | 16 | size_t pending_bytes() const { return _pending_bytes.load(std::memory_order_relaxed); } |
311 | | |
312 | | /// Return accepted tasks still waiting in the FIFO queue, excluding active workers. |
313 | | size_t queued_count() const; |
314 | | |
315 | | /// Return buffer-capacity bytes still waiting in the FIFO queue. |
316 | 11 | size_t queued_bytes() const { return _queued_bytes.load(std::memory_order_relaxed); } |
317 | | |
318 | | /// Return tasks currently owned by workers. |
319 | 8 | size_t active_task_count() const { return _active_task_count.load(std::memory_order_relaxed); } |
320 | | |
321 | | /// Return buffer-capacity bytes currently owned by workers. |
322 | 7 | size_t active_bytes() const { return _active_bytes.load(std::memory_order_relaxed); } |
323 | | |
324 | | /// Return worker loops that are currently alive. |
325 | 809 | size_t running_worker_count() const { |
326 | 809 | return _running_worker_count.load(std::memory_order_relaxed); |
327 | 809 | } |
328 | | |
329 | | /// Return bytes currently held by tracked task buffers. |
330 | 824 | int64_t buffer_memory_bytes() const { return _mem_tracker->consumption(); } |
331 | | |
332 | | /// Return tasks displaced by full-queue admission. |
333 | | uint64_t evicted_oldest_count() const; |
334 | | |
335 | | /// Return the current rolling P99 wait to acquire the FIFO mutex. |
336 | | int64_t queue_lock_wait_p99_us() const; |
337 | | |
338 | | /// Return the current rolling P99 FIFO mutex critical-section duration. |
339 | | int64_t queue_lock_hold_p99_us() const; |
340 | | |
341 | | private: |
342 | | class Worker; |
343 | | |
344 | | enum class TaskFinalizationReason : uint8_t { |
345 | | WORKER_FINISHED, |
346 | | EVICTED_OLDEST, |
347 | | }; |
348 | | |
349 | | /// Owns bvar registration and translates manager events into coherent metric updates. |
350 | | class Metrics; |
351 | | |
352 | | /// Resize the owned worker set while `_lifecycle_mutex` is held and `_worker_pool` exists. |
353 | | /// The pool's minimum thread count is kept equal to the long-running Worker task count so a |
354 | | /// task is never accepted without a backing OS thread. |
355 | | Status _resize_workers_locked(size_t worker_count); |
356 | | |
357 | | /// Stop and join workers in `[keep_worker_count, _workers.size())` while the lifecycle mutex is |
358 | | /// held. Stop requests are published under `_queue_mutex` before waking the worker loops. |
359 | | void _stop_workers_locked(size_t keep_worker_count); |
360 | | |
361 | | /// Process one task already moved from queued to active ownership. |
362 | | void _process_task(AsyncCacheWriteTask task); |
363 | | |
364 | | /// Move the oldest queued task to active ownership. |
365 | | bool _try_activate_task(AsyncCacheWriteTask* task); |
366 | | |
367 | | /// Revalidate epoch/cache state and persist the task's still-empty complete blocks. |
368 | | Status _persist_task(const AsyncCacheWriteTask& task); |
369 | | |
370 | | /// Move one active task to its terminal state outside the queue lock. |
371 | | void _complete_active_task(AsyncCacheWriteTask task); |
372 | | |
373 | | /// Record the terminal reason and let the task run its owner cleanup without the queue lock. |
374 | | void _complete_task(AsyncCacheWriteTask task, TaskFinalizationReason reason); |
375 | | |
376 | | BlockFileCache* _cache; |
377 | | atomic_shared_ptr<const AsyncCacheWriteManagerOptions> _options; |
378 | | std::deque<AsyncCacheWriteTask> _queue; |
379 | | mutable std::mutex _queue_mutex; |
380 | | std::condition_variable _queue_cv; |
381 | | // Learned from the first submission and protected by `_queue_mutex`. |
382 | | size_t _task_buffer_size {0}; |
383 | | // Pending covers all accepted tasks, while queued and active are its disjoint ownership states: |
384 | | // pending_count = queue.size() + active_task_count |
385 | | // pending_bytes = queued_bytes + active_bytes |
386 | | // Byte state is maintained directly and is authoritative for memory admission. Production |
387 | | // tasks currently have a fixed cache-block buffer capacity, including a partial EOF write, but |
388 | | // byte accounting deliberately does not depend on deriving bytes from task counts. |
389 | | std::atomic<size_t> _pending_count {0}; |
390 | | std::atomic<size_t> _pending_bytes {0}; |
391 | | std::atomic<size_t> _queued_bytes {0}; |
392 | | std::atomic<size_t> _active_task_count {0}; |
393 | | std::atomic<size_t> _active_bytes {0}; |
394 | | std::atomic<size_t> _running_worker_count {0}; |
395 | | std::atomic<size_t> _active_get_or_set_count {0}; |
396 | | std::atomic<size_t> _active_append_count {0}; |
397 | | std::atomic<size_t> _active_finalize_count {0}; |
398 | | std::atomic<bool> _accepting {true}; |
399 | | std::atomic<size_t> _active_submitters {0}; |
400 | | std::atomic<bool> _started {false}; |
401 | | std::atomic<uint64_t> _cache_epoch {1}; |
402 | | std::shared_ptr<AsyncCacheWriteEpochRegistry> _write_epoch_registry; |
403 | | |
404 | | std::shared_ptr<MemTrackerLimiter> _mem_tracker; |
405 | | std::unique_ptr<Metrics> _metrics; |
406 | | std::unique_ptr<ThreadPool> _worker_pool; |
407 | | std::atomic<size_t> _configured_worker_count {0}; |
408 | | // Serializes start, resize, and shutdown, including all changes to `_workers`. |
409 | | std::mutex _lifecycle_mutex; |
410 | | // Protected by `_lifecycle_mutex`. Worker stop state is owned by each Worker. |
411 | | std::vector<std::shared_ptr<Worker>> _workers; |
412 | | }; |
413 | | |
414 | | } // namespace doris::io |