PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
read_through_cache.hpp
Go to the documentation of this file.
1#pragma once
2
4
5#include <cstring>
6#include <limits>
7#include <new>
8#include <utility>
9
10namespace pineforge::detail {
11
13 std::uint64_t backing_identity = 0;
14 std::uint64_t generation = 0;
15 std::uint64_t page_number = 0;
16};
18 return a.backing_identity == b.backing_identity && a.generation == b.generation
19 && a.page_number == b.page_number;
20}
21inline bool operator!=(ReadThroughCacheKey a, ReadThroughCacheKey b) noexcept { return !(a == b); }
22
23enum class ReadThroughCacheReplacement : std::uint32_t { direct_mapped, clock, lru };
25
32
33// Completed bytes, including a prefix completed before failure. Only an
34// explicit backing guarantee permits restoration of a failed put's old tag.
35// A short "ok" is an I/O error, never a successful fill or an unchanged write.
48 void* context = nullptr;
49 void* (*allocate)(void*, std::size_t) noexcept = nullptr;
50 void (*deallocate)(void*, void*, std::size_t) noexcept = nullptr;
51};
52
53// The owner supplies journal ordering, rollback-only state and publication.
54// Every write in write-back mode, including bypass, goes through this port.
55// discard_allowed is affirmative only after owner rollback/abandonment.
57 void* context = nullptr;
59 bool (*discard_allowed)(void*) noexcept = nullptr;
60};
62 std::uint64_t get_calls = 0;
63 std::uint64_t put_calls = 0;
64 std::uint64_t pin_calls = 0;
65 std::uint64_t hits = 0;
66 std::uint64_t misses = 0;
67 std::uint64_t bypass_reads = 0;
68 std::uint64_t backing_read_bytes = 0;
69 std::uint64_t backing_write_bytes = 0;
70 std::uint64_t evictions = 0;
71 std::uint64_t dirty_evictions = 0;
72 std::uint64_t allocation_bypass_count = 0;
73 std::uint64_t current_pins = 0;
74 std::uint64_t pin_high_water = 0;
75 std::uint64_t current_payload_bytes = 0;
76 std::uint64_t peak_payload_bytes = 0;
77 std::uint64_t current_metadata_bytes = 0;
78 std::uint64_t peak_metadata_bytes = 0;
79 std::uint64_t current_bytes = 0;
80 std::uint64_t peak_bytes = 0;
81 std::uint64_t io_errors = 0;
82 std::uint64_t flush_errors = 0;
83};
84
85// One serialized, exclusive owner; no callback may reenter this instance.
86// Callbacks, contexts and all lease workspaces are borrowed. The cache must
87// outlive its leases. Every span is a nonnull 1..page_size byte page PREFIX:
88// no speculative tail read, padding, logical extent inference or negative
89// cache. Owners chunk longer values and perform any intra-page slicing.
90//
91// Optional allocation contains ALL per-slot payload, tags, pins and policy
92// metadata. The fixed control object and mandatory caller transfer workspace
93// are disclosed separately. No directory or allocation grows with keys.
94// Cache state, replacement state and statistics never enter logical hashes.
96 struct Slot {
98 std::size_t valid_bytes = 0;
99 std::size_t pins = 0;
100 std::uint64_t age = 0;
101 bool valid = false;
102 bool dirty = false;
103 bool recent = false;
104 };
105 static constexpr std::size_t no_slot = std::numeric_limits<std::size_t>::max();
106
107public:
108 class ReadLease {
109 public:
110 ReadLease() noexcept = default;
111 ~ReadLease() noexcept { reset(); }
112 ReadLease(const ReadLease&) = delete;
113 ReadLease& operator=(const ReadLease&) = delete;
114 ReadLease(ReadLease&& other) noexcept { *this = std::move(other); }
115 ReadLease& operator=(ReadLease&& other) noexcept;
116 ScratchStatus status() const noexcept { return status_; }
117 explicit operator bool() const noexcept { return status_ == ScratchStatus::ok; }
118 bool detached() const noexcept { return bool(*this) && slot_ == no_slot; }
119 ScratchBytes value() const & noexcept { return bytes_; }
120 ScratchBytes value() const && = delete;
121 void reset() noexcept;
122
123 private:
124 friend class ReadThroughCache;
125 explicit ReadLease(ScratchStatus status) noexcept : status_(status) {}
126 ReadLease(ReadThroughCache* owner, std::size_t slot, ScratchBytes bytes) noexcept
127 : owner_(owner), slot_(slot), bytes_(bytes), status_(ScratchStatus::ok) {}
128 ReadThroughCache* owner_ = nullptr;
129 std::size_t slot_ = no_slot;
130 ScratchBytes bytes_{};
132 };
133
135 ReadThroughCacheAllocator allocator = {},
136 ReadThroughCacheTransactionOrder transaction = {}) noexcept
137 : policy_(policy), backing_(backing), allocator_(allocator), transaction_(transaction) {
138 const auto line = line_bytes(policy.page_size);
139 if (!line || !backing.read || bool(allocator.allocate) != bool(allocator.deallocate)
145 || (write_back() && !transaction.write)) {
147 return;
148 }
149 slots_ = policy.capacity_bytes / line;
150 }
151 ~ReadThroughCache() noexcept {
152 // No flush, backing operation or implicit commit, even for dirty lines.
153 if (!memory_) return;
154 for (std::size_t i = 0; i < slots_; ++i) slot(i).~Slot();
155 if (allocator_.deallocate) allocator_.deallocate(allocator_.context, memory_, allocated_bytes_);
156 else ::operator delete(memory_);
157 }
162
163 ScratchStatus status() const noexcept { return status_; }
164 ReadThroughCachePolicy policy() const noexcept { return policy_; }
165 ReadThroughCacheStats stats() const noexcept { return stats_; }
166 const ReportScratchIoError* carried_failure() const noexcept { return failure_; }
167 static std::size_t line_bytes(std::size_t page_size) noexcept {
168 return page_size && page_size <= std::numeric_limits<std::size_t>::max() - sizeof(Slot)
169 ? sizeof(Slot) + page_size : 0;
170 }
171 static std::size_t control_bytes() noexcept { return sizeof(ReadThroughCache); }
172 std::size_t transfer_workspace_bytes() const noexcept { return policy_.page_size; }
173 std::size_t slot_count() const noexcept { return slots_; }
174
176 increment(stats_.get_calls);
177 failure_ = nullptr;
178 if (!valid_span(destination.data, destination.size)) return ScratchStatus::programming_error;
179 return load(key, destination).status;
180 }
181 ReadLease pin(ReadThroughCacheKey key, ScratchBuffer caller_workspace) noexcept {
182 increment(stats_.pin_calls);
183 failure_ = nullptr;
184 if (!valid_span(caller_workspace.data, caller_workspace.size))
186 const auto loaded = load(key, caller_workspace);
187 if (loaded.status != ScratchStatus::ok) return ReadLease(loaded.status);
188 if (loaded.slot != no_slot) ++slot(loaded.slot).pins;
189 increment(stats_.current_pins);
190 if (stats_.current_pins > stats_.pin_high_water)
191 stats_.pin_high_water = stats_.current_pins;
192 return ReadLease(this, loaded.slot,
193 {loaded.slot == no_slot ? caller_workspace.data : payload(loaded.slot),
194 caller_workspace.size});
195 }
196 void unpin(ReadLease& lease) noexcept {
197 if (lease.owner_ == this) lease.reset();
198 }
199
201 increment(stats_.put_calls);
202 failure_ = nullptr;
203 if (!valid_span(source.data, source.size) || (!write_back() && !backing_.write))
205 allocate_slots();
206 const auto old = find(key);
207 // A shorter prefix must first preserve any dirty suffix in backing.
208 if (old != no_slot && slot(old).dirty && source.size < slot(old).valid_bytes) {
209 const auto result = flush_slot(old);
210 if (result != ScratchStatus::ok) return result;
211 }
212 const auto target = old != no_slot && !slot(old).pins ? old : choose(key);
213 const bool dirty_victim = target != no_slot && target != old && slot(target).dirty;
214 if (dirty_victim) {
215 const auto result = flush_slot(target);
216 if (result != ScratchStatus::ok) return result;
217 }
218 const Slot old_tag = old == no_slot ? Slot{} : slot(old);
219 const Slot target_tag = target == no_slot ? Slot{} : slot(target);
220 if (old != no_slot) invalidate_slot(old);
221 if (target != no_slot) invalidate_slot(target);
222
223 if (write_back() && target != no_slot) {
224 count_eviction(target_tag, key, dirty_victim);
225 install(target, key, source, true);
226 return ScratchStatus::ok;
227 }
228
229 // Tags are already invalid. Bytes are untouched until complete I/O,
230 // allowing restoration only under an explicit failure guarantee.
231 const auto result = write(key, source, false);
232 if (result.status != ScratchStatus::ok) {
233 if (old != no_slot && (old_tag.dirty || result.unchanged_on_failure))
234 slot(old) = old_tag;
235 if (target != no_slot && target != old) slot(target) = target_tag;
236 return result.status;
237 }
238 if (target != no_slot) {
239 count_eviction(target_tag, key, dirty_victim);
240 install(target, key, source, false);
241 }
242 return ScratchStatus::ok;
243 }
244
246 return invalidate_if([key](const Slot& line) noexcept { return line.key == key; });
247 }
249 return invalidate_if([](const Slot&) noexcept { return true; });
250 }
251 ScratchStatus invalidate_from(std::uint64_t identity, std::uint64_t generation,
252 std::uint64_t byte_offset) noexcept {
254 const auto first = byte_offset / policy_.page_size;
255 return invalidate_if([=](const Slot& line) noexcept {
256 return line.key.backing_identity == identity && line.key.generation == generation
257 && line.key.page_number >= first;
258 });
259 }
260 // Notify BEFORE owner I/O, including I/O that might partially fail.
261 ScratchStatus on_append(std::uint64_t identity, std::uint64_t generation,
262 std::uint64_t old_size_bytes) noexcept {
263 return invalidate_from(identity, generation, old_size_bytes);
264 }
265 ScratchStatus on_truncate(std::uint64_t identity, std::uint64_t generation,
266 std::uint64_t new_size_bytes) noexcept {
267 return invalidate_from(identity, generation, new_size_bytes);
268 }
269 ScratchStatus on_generation_change(std::uint64_t identity,
270 std::uint64_t new_generation) noexcept {
271 return invalidate_if([=](const Slot& line) noexcept {
272 return line.key.backing_identity == identity && line.key.generation != new_generation;
273 });
274 }
276 failure_ = nullptr;
277 if (!backing.read) return ScratchStatus::programming_error;
278 const auto result = invalidate_all();
279 if (result != ScratchStatus::ok) return result;
280 backing_ = backing;
281 return ScratchStatus::ok;
282 }
283 ScratchStatus flush() noexcept {
284 failure_ = nullptr;
286 if (memory_) {
287 for (std::size_t i = 0; i < slots_; ++i) {
288 const auto result = flush_slot(i);
289 if (result != ScratchStatus::ok) return result;
290 }
291 }
292 return ScratchStatus::ok;
293 }
295 failure_ = nullptr;
297 bool dirty = false;
298 if (memory_)
299 for (std::size_t i = 0; i < slots_; ++i) dirty = dirty || slot(i).dirty;
300 if (!dirty) return ScratchStatus::ok;
301 if (!transaction_.discard_allowed || !transaction_.discard_allowed(transaction_.context))
303 for (std::size_t i = 0; i < slots_; ++i)
304 if (slot(i).dirty) invalidate_slot(i);
305 return ScratchStatus::ok;
306 }
307
308private:
309 struct Loaded {
311 std::size_t slot = no_slot;
312 };
313 static void increment(std::uint64_t& counter, std::uint64_t count = 1) noexcept {
314 const auto available = std::numeric_limits<std::uint64_t>::max() - counter;
315 counter += count <= available ? count : available;
316 }
317 bool write_back() const noexcept {
319 }
320 bool valid_span(const void* data, std::size_t size) const noexcept {
321 return status_ == ScratchStatus::ok && data && size && size <= policy_.page_size;
322 }
323 Slot& slot(std::size_t i) noexcept {
324 // Each Slot has an explicit lifetime in aligned allocated storage.
325 return *std::launder(reinterpret_cast<Slot*>(
326 static_cast<std::byte*>(memory_) + i * sizeof(Slot)));
327 }
328 const Slot& slot(std::size_t i) const noexcept {
329 return *std::launder(reinterpret_cast<const Slot*>(
330 static_cast<const std::byte*>(memory_) + i * sizeof(Slot)));
331 }
332 std::byte* payload(std::size_t i) noexcept {
333 return static_cast<std::byte*>(memory_) + slots_ * sizeof(Slot) + i * policy_.page_size;
334 }
335 void allocate_slots() noexcept {
336 if (!slots_ || allocation_attempted_) return;
337 allocation_attempted_ = true;
338 // slots_ = capacity / line_bytes, so both products and their sum fit
339 // and requested allocation is <= capacity_bytes, including metadata.
340 allocated_bytes_ = slots_ * line_bytes(policy_.page_size);
341 memory_ = allocator_.allocate
342 ? allocator_.allocate(allocator_.context, allocated_bytes_)
343 : ::operator new(allocated_bytes_, std::nothrow);
344 if (!memory_) {
345 slots_ = 0;
346 allocated_bytes_ = 0;
347 increment(stats_.allocation_bypass_count);
348 return;
349 }
350 for (std::size_t i = 0; i < slots_; ++i)
351 ::new (static_cast<void*>(static_cast<std::byte*>(memory_) + i * sizeof(Slot))) Slot{};
352 stats_.current_payload_bytes = slots_ * policy_.page_size;
353 stats_.current_metadata_bytes = slots_ * sizeof(Slot);
354 stats_.current_bytes = allocated_bytes_;
355 stats_.peak_payload_bytes = stats_.current_payload_bytes;
356 stats_.peak_metadata_bytes = stats_.current_metadata_bytes;
357 stats_.peak_bytes = stats_.current_bytes;
358 }
359 std::size_t mapped(ReadThroughCacheKey key) const noexcept {
360 const auto hash = key.page_number
361 ^ (key.backing_identity * 0x9e3779b97f4a7c15ULL)
362 ^ (key.generation * 0xbf58476d1ce4e5b9ULL);
363 return static_cast<std::size_t>(hash % slots_);
364 }
365 std::size_t find(ReadThroughCacheKey key) const noexcept {
366 if (!memory_) return no_slot;
367 if (policy_.replacement == ReadThroughCacheReplacement::direct_mapped) {
368 const auto i = mapped(key);
369 return slot(i).valid && slot(i).key == key ? i : no_slot;
370 }
371 for (std::size_t i = 0; i < slots_; ++i)
372 if (slot(i).valid && slot(i).key == key) return i;
373 return no_slot;
374 }
375 std::size_t choose(ReadThroughCacheKey key) noexcept {
376 if (!memory_) return no_slot;
377 if (policy_.replacement == ReadThroughCacheReplacement::direct_mapped) {
378 const auto i = mapped(key);
379 return slot(i).pins ? no_slot : i;
380 }
381 for (std::size_t i = 0; i < slots_; ++i)
382 if (!slot(i).valid && !slot(i).pins) return i;
383 if (policy_.replacement == ReadThroughCacheReplacement::clock) {
384 // Two bounded passes suffice: the first clears reference bits.
385 for (unsigned pass = 0; pass < 2; ++pass) {
386 for (std::size_t count = 0; count < slots_; ++count) {
387 const auto i = clock_hand_;
388 clock_hand_ = clock_hand_ + 1 == slots_ ? 0 : clock_hand_ + 1;
389 if (slot(i).pins) continue;
390 if (!slot(i).recent) return i;
391 slot(i).recent = false;
392 }
393 }
394 return no_slot;
395 }
396 std::size_t victim = no_slot;
397 for (std::size_t i = 0; i < slots_; ++i)
398 if (!slot(i).pins && (victim == no_slot || slot(i).age > slot(victim).age))
399 victim = i;
400 return victim;
401 }
402 void touch(std::size_t i) noexcept {
403 slot(i).recent = true;
404 if (policy_.replacement == ReadThroughCacheReplacement::lru) {
405 for (std::size_t j = 0; j < slots_; ++j) {
406 auto& line = slot(j);
407 if (j != i && line.valid && line.age != std::numeric_limits<std::uint64_t>::max())
408 ++line.age;
409 }
410 slot(i).age = 0;
411 }
412 }
413 void invalidate_slot(std::size_t i) noexcept {
414 slot(i).valid = false;
415 slot(i).dirty = false;
416 // Do not change pins, payload or an existing lease's snapshot.
417 }
418 void install(std::size_t i, ReadThroughCacheKey key, ScratchBytes bytes, bool dirty) noexcept {
419 std::memmove(payload(i), bytes.data, bytes.size);
420 auto& line = slot(i);
421 line.key = key;
422 line.valid_bytes = bytes.size;
423 line.dirty = dirty;
424 line.valid = true; // Publish only complete bytes.
425 touch(i);
426 }
427 void count_eviction(const Slot& previous, ReadThroughCacheKey key, bool dirty) noexcept {
428 if (previous.valid && previous.key != key) {
429 increment(stats_.evictions);
430 if (dirty) increment(stats_.dirty_evictions);
431 }
432 }
433 ReadThroughCacheIoResult normalize(ReadThroughCacheIoResult result,
434 std::size_t requested, bool reading) noexcept {
435 const auto completed = result.transferred_bytes <= requested
436 ? result.transferred_bytes : requested;
437 increment(reading ? stats_.backing_read_bytes : stats_.backing_write_bytes, completed);
438 if (result.transferred_bytes > requested) {
439 result.status = ScratchStatus::programming_error;
440 result.unchanged_on_failure = false;
441 } else if (result.status == ScratchStatus::ok && result.transferred_bytes != requested) {
442 result.status = ScratchStatus::io_error;
443 result.unchanged_on_failure = false;
444 }
445 failure_ = result.status == ScratchStatus::ok ? nullptr : result.failure;
446 if (result.status == ScratchStatus::io_error) increment(stats_.io_errors);
447 return result;
448 }
449 ReadThroughCacheIoResult write(ReadThroughCacheKey key, ScratchBytes bytes,
450 bool flushing) noexcept {
451 const auto result = normalize(write_back()
452 ? transaction_.write(transaction_.context, key, bytes)
453 : backing_.write(backing_.context, key, bytes), bytes.size, false);
454 if (flushing && result.status != ScratchStatus::ok) increment(stats_.flush_errors);
455 return result;
456 }
457 ScratchStatus flush_slot(std::size_t i) noexcept {
458 auto& line = slot(i);
459 if (!line.valid || !line.dirty) return ScratchStatus::ok;
460 const auto result = write(line.key, {payload(i), line.valid_bytes}, true);
461 if (result.status == ScratchStatus::ok) line.dirty = false;
462 // On ANY failure the dirty line stays valid and authoritative.
463 return result.status;
464 }
465 Loaded load(ReadThroughCacheKey key, ScratchBuffer destination) noexcept {
466 allocate_slots();
467 const auto old = find(key);
468 if (old != no_slot && slot(old).valid_bytes >= destination.size) {
469 increment(stats_.hits);
470 touch(old);
471 std::memmove(destination.data, payload(old), destination.size);
472 return {ScratchStatus::ok, old};
473 }
474 increment(stats_.misses);
475 const auto target = old != no_slot && !slot(old).pins ? old : choose(key);
476 const Slot previous = target == no_slot ? Slot{} : slot(target);
477 const bool dirty_prefix = old != no_slot && slot(old).dirty;
478 if (target != no_slot) {
479 if (!previous.dirty) {
480 count_eviction(previous, key, false);
481 invalidate_slot(target);
482 }
483 } else {
484 increment(stats_.bypass_reads);
485 }
486 if (old != no_slot && !dirty_prefix) invalidate_slot(old);
487 // EVERY miss calls the reader, even if a later dirty eviction fails.
488 // Keep authoritative dirty prefixes/victims until that I/O succeeds.
489 const auto result = normalize(backing_.read(backing_.context, key, destination),
490 destination.size, true);
491 if (result.status != ScratchStatus::ok) return {result.status};
492 if (dirty_prefix) {
493 // A read longer than a pending prefix combines backing suffix
494 // bytes with that authoritative prefix, without an implicit flush.
495 std::memmove(destination.data, payload(old), slot(old).valid_bytes);
496 }
497 if (target != no_slot) {
498 if (previous.dirty && target != old) {
499 const auto flushed = flush_slot(target);
500 if (flushed != ScratchStatus::ok) return {flushed};
501 count_eviction(previous, key, true);
502 }
503 if (old != no_slot) invalidate_slot(old);
504 invalidate_slot(target);
505 install(target, key, {destination.data, destination.size}, dirty_prefix);
506 }
507 return {ScratchStatus::ok, target};
508 }
509 template<class Predicate>
510 ScratchStatus invalidate_if(Predicate matches) noexcept {
511 failure_ = nullptr;
513 if (!memory_) return ScratchStatus::ok;
514 // Precheck the entire selection: a refusal must not partly invalidate.
515 for (std::size_t i = 0; i < slots_; ++i)
516 if (slot(i).valid && slot(i).dirty && matches(slot(i)))
518 for (std::size_t i = 0; i < slots_; ++i)
519 if (slot(i).valid && matches(slot(i))) invalidate_slot(i);
520 return ScratchStatus::ok;
521 }
522 void release_pin(std::size_t i) noexcept {
523 if (i != no_slot) --slot(i).pins;
524 if (stats_.current_pins) --stats_.current_pins;
525 }
526
527 ReadThroughCachePolicy policy_;
528 ReadThroughCacheBacking backing_;
529 ReadThroughCacheAllocator allocator_;
530 ReadThroughCacheTransactionOrder transaction_;
531 ReadThroughCacheStats stats_{};
533 const ReportScratchIoError* failure_ = nullptr;
534 void* memory_ = nullptr;
535 std::size_t slots_ = 0;
536 std::size_t allocated_bytes_ = 0;
537 std::size_t clock_hand_ = 0;
538 bool allocation_attempted_ = false;
539};
540
542 ReadLease&& other) noexcept {
543 if (this != &other) {
544 reset();
545 owner_ = other.owner_;
546 slot_ = other.slot_;
547 bytes_ = other.bytes_;
548 status_ = other.status_;
549 other.owner_ = nullptr;
550 other.slot_ = no_slot;
551 other.bytes_ = {};
552 other.status_ = ScratchStatus::programming_error;
553 }
554 return *this;
555}
557 if (owner_) owner_->release_pin(slot_);
558 owner_ = nullptr;
559 slot_ = no_slot;
560 bytes_ = {};
562}
563
564} // namespace pineforge::detail
ScratchBytes value() const &&=delete
ReadLease & operator=(const ReadLease &)=delete
ScratchStatus status() const noexcept
ScratchStatus rebind(ReadThroughCacheBacking backing) noexcept
ScratchStatus on_generation_change(std::uint64_t identity, std::uint64_t new_generation) noexcept
ReadThroughCachePolicy policy() const noexcept
ScratchStatus get(ReadThroughCacheKey key, ScratchBuffer destination) noexcept
ReadThroughCache(const ReadThroughCache &)=delete
ScratchStatus on_truncate(std::uint64_t identity, std::uint64_t generation, std::uint64_t new_size_bytes) noexcept
ReadThroughCache(ReadThroughCache &&)=delete
static std::size_t line_bytes(std::size_t page_size) noexcept
void unpin(ReadLease &lease) noexcept
std::size_t transfer_workspace_bytes() const noexcept
ReadThroughCache & operator=(const ReadThroughCache &)=delete
ScratchStatus invalidate_from(std::uint64_t identity, std::uint64_t generation, std::uint64_t byte_offset) noexcept
ScratchStatus on_append(std::uint64_t identity, std::uint64_t generation, std::uint64_t old_size_bytes) noexcept
static std::size_t control_bytes() noexcept
ReadThroughCache & operator=(ReadThroughCache &&)=delete
ScratchStatus put(ReadThroughCacheKey key, ScratchBytes source) noexcept
ReadThroughCacheStats stats() const noexcept
ScratchStatus invalidate(ReadThroughCacheKey key) noexcept
std::size_t slot_count() const noexcept
ReadLease pin(ReadThroughCacheKey key, ScratchBuffer caller_workspace) noexcept
const ReportScratchIoError * carried_failure() const noexcept
ReadThroughCache(ReadThroughCachePolicy policy, ReadThroughCacheBacking backing, ReadThroughCacheAllocator allocator={}, ReadThroughCacheTransactionOrder transaction={}) noexcept
bool operator==(ReadThroughCacheKey a, ReadThroughCacheKey b) noexcept
bool operator!=(ReadThroughCacheKey a, ReadThroughCacheKey b) noexcept
void(* deallocate)(void *, void *, std::size_t) noexcept
ReadThroughCacheIoResult(* read)(void *, ReadThroughCacheKey, ScratchBuffer) noexcept
ReadThroughCacheIoResult(* write)(void *, ReadThroughCacheKey, ScratchBytes) noexcept
ReadThroughCacheIoResult(* write)(void *, ReadThroughCacheKey, ScratchBytes) noexcept