PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
id_pair_store.hpp
Go to the documentation of this file.
1#pragma once
2
3// CR-150 L19l-C. Exact string / string-pair tables with no per-key RAM
4// directory. Like L19a's successor-root file, scratch files are private,
5// unlinked at creation, close-on-exec, and accessed with pread/pwrite.
9
10#include <algorithm>
11#include <array>
12#include <cerrno>
13#include <cstddef>
14#include <cstdint>
15#include <cstdlib>
16#include <fcntl.h>
17#include <iterator>
18#include <limits>
19#include <map>
20#include <memory>
21#include <optional>
22#include <stdexcept>
23#include <string>
24#include <string_view>
25#include <system_error>
26#include <type_traits>
27#include <unistd.h>
28#include <utility>
29
30namespace pineforge::detail {
31
32// IdPairScratchFile and its errno-preserving auxiliary failure live in
33// id_pair_scratch_file.hpp, shared with the ordered-fold implementation above.
34// One 40-byte record plus both strings per distinct key ever seen, and a
35// 16-byte on-disk hash slot at <= 1/2 load (32..64 bytes/key after warmup).
36// Replacing a value, erasing and recurring reuse the original record.
37// Single keys and pairs are separate domains; ("x", "") is not single "x".
38// Hash collisions are always resolved by comparing the complete byte strings.
39template<class Value, bool MaintainSortedFold = false>
41public:
42 // Observe the actual comparison and byte-fold sites, including the old
43 // slab selection loop, so a lab can measure fail-before with these
44 // observation-only hunks applied to the previous implementation.
46 std::uint64_t row_comparisons = 0;
47 std::uint64_t byte_steps = 0; // All 257 seeds, not just input bytes.
48 std::uint64_t compositions = 0;
49 };
50
51private:
52 static_assert(std::is_integral_v<Value> && sizeof(Value) == 8,
53 "id auxiliary values are fixed-width 64-bit integers");
54 using MemoryKey = std::pair<std::string, std::optional<std::string>>;
55 using MemoryMap = std::map<MemoryKey, Value>;
56
57 struct Slot {
58 std::uint64_t hash = 0;
59 std::uint64_t offset_plus_one = 0;
60 };
61 struct Header {
62 std::uint64_t hash = 0;
63 std::uint64_t first_size = 0;
64 std::uint64_t second_size = 0;
65 Value value = 0;
66 std::uint64_t flags = 0;
67 };
68 static_assert(sizeof(Slot) == 16 && sizeof(Header) == 40,
69 "id scratch layout must have no padding");
70 static constexpr std::uint64_t kLive = 1;
71 static constexpr std::uint64_t kPair = 2;
72 static constexpr std::uint64_t kInitialSlots = 64;
73
74 // Opt-in only for the consumed-cycle single-key table. Ordinary minima
75 // tables keep their existing disk layout, lookup and iteration paths.
76 struct NoFold {};
77 struct FoldState {
78 IdPairOrderedFold ordered;
79 IdPairSlabFold bounded;
80 Header pending_header;
81 Slot pending_slot;
82 std::uint64_t pending_offset = 0, pending_at = 0;
83 bool pending = false, slot_pending = false;
84 FixtureFoldWork* fixture_work = nullptr;
85 };
86
87public:
88 struct Entry {
89 std::string first;
90 Value second = 0;
91 std::optional<std::string> from_entry;
92 };
93
94 // Streaming iteration owns only its current strings. It is invalidated
95 // by mutation; no adapter caller keeps an iterator across a write.
97 public:
98 using iterator_category = std::input_iterator_tag;
100 using difference_type = std::ptrdiff_t;
101 using pointer = const Entry*;
102 using reference = const Entry&;
103
104 const_iterator() = default;
105 reference operator*() const { return *entry_; }
106 pointer operator->() const { return &*entry_; }
108 if (owner_->memory_) {
109 ++memory_at_;
110 load_memory();
111 } else {
112 offset_ = next_;
113 load_disk();
114 }
115 return *this;
116 }
118 const_iterator before = *this;
119 ++*this;
120 return before;
121 }
122 bool operator==(const const_iterator& other) const {
123 if (owner_ != other.owner_) return false;
124 if (!entry_ || !other.entry_) return !entry_ && !other.entry_;
125 return owner_->memory_ ? memory_at_ == other.memory_at_ : offset_ == other.offset_;
126 }
127 bool operator!=(const const_iterator& other) const { return !(*this == other); }
128
129 private:
130 friend class IdPairStore;
131 const_iterator(const IdPairStore* owner, bool end) : owner_(owner) {
132 if (end) return;
133 if (owner_->memory_) {
134 memory_at_ = owner_->memory_->begin();
135 load_memory();
136 } else {
137 load_disk();
138 }
139 }
140 void load_memory() {
141 entry_.reset();
142 if (memory_at_ != owner_->memory_->end())
143 entry_.emplace(Entry{memory_at_->first.first, memory_at_->second,
144 memory_at_->first.second});
145 }
146 void load_disk() {
147 entry_.reset();
148 while (offset_ < owner_->data_bytes_) {
149 const Header header = owner_->read_header(offset_);
150 next_ = offset_ + sizeof(Header) + header.first_size + header.second_size;
151 if ((header.flags & kLive) != 0) {
152 Entry row;
153 row.first.resize(static_cast<std::size_t>(header.first_size));
154 owner_->read_data(offset_ + sizeof(Header), row.first.data(), row.first.size());
155 row.second = header.value;
156 if ((header.flags & kPair) != 0) {
157 row.from_entry.emplace(static_cast<std::size_t>(header.second_size), '\0');
158 owner_->read_data(offset_ + sizeof(Header) + header.first_size,
159 row.from_entry->data(), row.from_entry->size());
160 }
161 entry_.emplace(std::move(row));
162 return;
163 }
164 offset_ = next_;
165 }
166 }
167 const IdPairStore* owner_ = nullptr;
168 std::uint64_t offset_ = 0;
169 std::uint64_t next_ = 0;
170 typename MemoryMap::const_iterator memory_at_{};
171 std::optional<Entry> entry_;
172 };
173
174 IdPairStore() = default;
175 IdPairStore(const IdPairStore&) = delete;
177 IdPairStore(IdPairStore&& other) noexcept { swap(other); }
178 IdPairStore& operator=(IdPairStore&& other) noexcept {
179 if (this != &other) {
180 IdPairStore moved(std::move(other));
181 swap(moved);
182 }
183 return *this;
184 }
185
186 void set_directory(const std::string& directory) {
187 if ((data_.opened() || index_.opened()) && directory != directory_)
188 throw std::logic_error("id table directory changed during run");
189 directory_ = directory;
190 }
191 void clear() noexcept {
192 data_.reset();
193 index_.reset();
194 data_bytes_ = 0;
195 slots_ = slab_ ? fixed_slots_ : 0;
196 used_ = 0;
197 size_ = 0;
198 if constexpr (MaintainSortedFold) {
199 fold_.ordered.clear();
200 fold_.bounded.reset();
201 fold_.pending = fold_.slot_pending = false;
202 }
203 if (memory_) memory_->clear();
204 if (slab_) {
205 slab_->reset(data_resource_);
206 slab_->reset(index_resource_);
207 slab_->extend(index_resource_, slots_ * sizeof(Slot));
208 }
209 }
210 std::size_t size() const noexcept { return size_; }
211 bool empty() const noexcept { return size_ == 0; }
212 std::uint64_t disk_bytes() const noexcept {
213 if (slab_) return 0;
214 const auto table = data_bytes_ + slots_ * sizeof(Slot);
215 if constexpr (MaintainSortedFold) {
216 const auto ordered = fold_.ordered.disk_bytes();
217 const auto maximum = std::numeric_limits<std::uint64_t>::max();
218 return ordered > maximum - table ? maximum : table + ordered;
219 }
220 return table;
221 }
222 const_iterator begin() const { return const_iterator(this, false); }
223 const_iterator end() const { return const_iterator(this, true); }
224
225 // Bind once after reset, before the first key. Exactly one fixed index
226 // and one data slice; clear() reuses them without dropping their owner.
227 // An empty owner restores disk mode (an explicit fixture oracle survives).
228 void bind_scratch_tables(const std::shared_ptr<ScratchSlab>& slab,
230 if (used_ != 0 || size_ != 0 || data_.opened() || index_.opened())
231 throw std::logic_error("id table scratch backend changed after use");
232 if (slab && memory_)
233 throw std::logic_error("id table fixture oracle cannot bind production scratch");
234 std::uint64_t limit = 0;
236 limit = slab ? slab->manifest().K_e : 0;
238 limit = slab ? slab->manifest().K_c : 0;
239 else
240 throw std::logic_error("id table scratch resource pair is invalid");
241 const auto count = scratch_layout_detail::slots(limit);
242 if (slab) slab->require_extend(index, count * sizeof(Slot));
243 if constexpr (MaintainSortedFold) {
244 if (slab) {
245 fold_.bounded.configure(slab->region(data).capacity, limit, count);
246 // Fits the OLD 16*Q budget for every admitted manifest.
247 slab->require_extend(index, fold_.bounded.storage_bytes());
248 }
249 }
250 slab_ = slab;
251 data_resource_ = data;
252 index_resource_ = index;
253 key_limit_ = limit;
254 fixed_slots_ = count;
255 clear();
256 }
257
258 struct KeyView {
259 std::string_view first;
260 std::optional<std::string_view> second;
261 };
262
263 // enumerate(emit) is a repeatable, borrowed workset view. Revisit its
264 // prefix to deduplicate genuinely new ever-keys, including both domains.
265 // O(n^2) key comparisons, O(1) workspace; no growing pending-key directory.
266 // Neither a successful ticket nor a refusal changes extents or bytes.
267 template<class Enumerate>
268 void preflight_keys(const Enumerate& enumerate) const {
269 if (!slab_) return;
270 std::uint64_t keys = used_;
271 std::uint64_t extent = data_bytes_;
272 std::size_t ordinal = 0;
273 enumerate([&](KeyView key) {
274 auto first = key.first;
275 auto second = key.second;
276 if (data_resource_ == ScratchResource::consumed_data && second
277 && (first.find('\x1f') != std::string_view::npos
278 || second->find('\x1f') != std::string_view::npos)) {
279 throw ScratchCapacityError(data_resource_,
280 "order ids cannot contain the control character 0x1f");
281 }
282 if (data_resource_ == ScratchResource::consumed_data && !second) {
283 // Consumed singles join two ids with '\x1f'. A boundary
284 // after at most 64 bytes is safe because the source id
285 // components are rejected before this representation is made.
286 const auto separator = first.rfind('\x1f', 64);
287 if (separator != std::string_view::npos) {
288 second = first.substr(separator + 1);
289 first = first.substr(0, separator);
290 }
291 }
292 require_id_components(first.size(), second ? second->size() : 0);
293 const auto own_ordinal = ordinal++;
294 if (slots_ != 0 && locate(key.first, key.second,
295 hash_for_key(key.first, key.second)).second.offset_plus_one != 0)
296 return; // Updates and resurrection consume no new bytes/slots.
297 bool duplicate = false;
298 std::size_t prior = 0;
299 enumerate([&](KeyView earlier) {
300 if (prior++ < own_ordinal && earlier.first == key.first
301 && earlier.second == key.second) duplicate = true;
302 });
303 if (duplicate) return;
304 // Q=0 refuses here, before any Q-1 mask or probing.
305 preflight_new_key(keys, extent, key.first.size(),
306 key.second ? key.second->size() : 0);
307 });
308 }
309 void preflight_assign(std::string_view first,
310 std::optional<std::string_view> second = std::nullopt) const {
311 preflight_keys([&](const auto& emit) { emit(KeyView{first, second}); });
312 }
313
314 // A single key formed by first + separator + last, not the pair domain.
315 // PRECOMMIT can inspect the existing consumed-cycle string without making
316 // a temporary string allocation (including for an eventual refusal).
317 void preflight_joined_single(std::string_view first, char separator,
318 std::string_view last) const {
319 if (!slab_) return;
320 if (data_resource_ == ScratchResource::consumed_data
321 && (first.find('\x1f') != std::string_view::npos
322 || last.find('\x1f') != std::string_view::npos)) {
323 throw ScratchCapacityError(data_resource_,
324 "order ids cannot contain the control character 0x1f");
325 }
326 require_id_components(first.size(), last.size());
327 constexpr auto maximum = std::numeric_limits<std::uint64_t>::max();
328 if (first.size() >= maximum || last.size() > maximum - first.size() - 1)
329 throw ScratchCapacityError(data_resource_, scratch_byte_limit_message(data_resource_,
330 data_resource_ == ScratchResource::erased_data
331 ? slab_->manifest().B_e : slab_->manifest().B_c));
332 const auto length = static_cast<std::uint64_t>(first.size()) + 1 + last.size();
333 std::uint64_t hash = 1469598103934665603ULL;
334 const auto byte = [&](unsigned char value) { hash ^= value; hash *= 1099511628211ULL; };
335 byte(0); // The single-string domain used by key_hash.
336 for (unsigned shift = 0; shift < 64; shift += 8)
337 byte(static_cast<unsigned char>(length >> shift));
338 for (const unsigned char value : first) byte(value);
339 byte(static_cast<unsigned char>(separator));
340 for (const unsigned char value : last) byte(value);
341 if (fixture_hash_) hash = *fixture_hash_;
342 if (slots_ != 0) {
343 const auto found = locate_hash(hash, [&](std::uint64_t offset, const Header& header) {
344 return (header.flags & kPair) == 0 && header.first_size == length
345 && equal_string(offset + sizeof(Header), first.size(), first)
346 && equal_string(offset + sizeof(Header) + first.size(), 1,
347 std::string_view(&separator, 1))
348 && equal_string(offset + sizeof(Header) + first.size() + 1,
349 last.size(), last);
350 });
351 if (found.second.offset_plus_one != 0) return;
352 }
353 auto keys = used_;
354 auto extent = data_bytes_;
355 preflight_new_key(keys, extent, length, 0);
356 }
357
358 // The count and byte spelling are exactly the former hash_state loop.
359 // The explicit memory fixture remains an independent sorted reference.
361 static_assert(MaintainSortedFold, "ordered fold must be selected for this table");
362 sink.u(size_);
363 if (memory_) {
364 for (const auto& row : *memory_) {
365 sink.s(row.first.first);
366 sink.i(row.second);
367 }
368 } else if (slab_) {
369 sink.h = fold_.bounded.apply(sink.h);
370 } else {
371 sink.h = fold_.ordered.apply(sink.h);
372 }
373 }
375 data_.fixture_count_io(io); index_.fixture_count_io(io);
376 if constexpr (MaintainSortedFold) fold_.ordered.fixture_count_io(io);
377 }
378 void fixture_count_fold_work(FixtureFoldWork* work) noexcept {
379 static_assert(MaintainSortedFold, "fold work requires the ordered-fold table");
380 fold_.fixture_work = work;
381 }
382
383 // Explicit per-instance fixture seams, never chosen by a production binder.
384 // No global switch, environment knob, cached key roster or fallback on IO
385 // failure. clear() preserves this choice for a second run on the host.
387 if (slab_ || used_ != 0 || size_ != 0 || data_.opened() || index_.opened())
388 throw std::logic_error("id table fixture backend changed after use");
389 memory_ = std::make_unique<MemoryMap>();
390 }
391 void fixture_force_hash_collisions(std::uint64_t hash) {
392 if (used_ != 0 || size_ != 0 || data_.opened() || index_.opened())
393 throw std::logic_error("id table fixture hash changed after use");
394 fixture_hash_ = hash;
395 }
396
397 std::optional<Value> lookup(std::string_view id) const { return lookup_key(id, std::nullopt); }
398 std::optional<Value> lookup(std::string_view id, std::string_view from_entry) const {
399 return lookup_key(id, from_entry);
400 }
401 Value at(const std::string& id) const {
402 const auto found = lookup(id);
403 if (!found) throw std::out_of_range("id auxiliary key is absent");
404 return *found;
405 }
406 bool insert_or_assign(std::string_view id, Value value) {
407 return assign_key(id, std::nullopt, value);
408 }
409 bool insert_or_assign(std::string_view id, std::string_view from_entry, Value value) {
410 static_assert(!MaintainSortedFold, "consumed-cycle sorted fold accepts single keys");
411 return assign_key(id, from_entry, value);
412 }
413 void min_assign(std::string_view id, Value value) {
414 const auto old = lookup(id);
415 if (!old || value < *old) insert_or_assign(id, value);
416 }
417 void min_assign(std::string_view id, std::string_view from_entry, Value value) {
418 const auto old = lookup(id, from_entry);
419 if (!old || value < *old) insert_or_assign(id, from_entry, value);
420 }
421 bool erase(std::string_view id) { return erase_key(id, std::nullopt); }
422 bool erase(std::string_view id, std::string_view from_entry) {
423 return erase_key(id, from_entry);
424 }
425
426private:
427 void require_id_components(std::size_t first, std::size_t second) const {
428 if (first > 64 || second > 64)
429 throw ScratchCapacityError(data_resource_, "id longer than 64 bytes");
430 }
431 void preflight_new_key(std::uint64_t& keys, std::uint64_t& extent,
432 std::uint64_t first, std::uint64_t second) const {
433 if (keys >= key_limit_ || keys >= slots_ / 2
434 || keys >= std::numeric_limits<std::size_t>::max())
435 throw ScratchCapacityError(index_resource_,
436 scratch_count_limit_message(index_resource_, std::min(key_limit_, slots_ / 2)));
437 ++keys;
438 const auto append = [&](std::uint64_t bytes) {
439 const auto capacity = slab_->region(data_resource_).capacity;
440 if (extent > capacity || bytes > capacity - extent)
441 throw ScratchCapacityError(data_resource_,
442 scratch_byte_limit_message(data_resource_,
443 data_resource_ == ScratchResource::erased_data
444 ? slab_->manifest().B_e : slab_->manifest().B_c));
445 extent += bytes;
446 };
447 append(sizeof(Header));
448 append(first);
449 append(second);
450 }
451 void swap(IdPairStore& other) noexcept {
452 using std::swap;
453 swap(data_, other.data_);
454 swap(index_, other.index_);
455 swap(directory_, other.directory_);
456 swap(data_bytes_, other.data_bytes_);
457 swap(slots_, other.slots_);
458 swap(used_, other.used_);
459 swap(size_, other.size_);
460 swap(memory_, other.memory_);
461 swap(slab_, other.slab_);
462 swap(data_resource_, other.data_resource_);
463 swap(index_resource_, other.index_resource_);
464 swap(key_limit_, other.key_limit_);
465 swap(fixed_slots_, other.fixed_slots_);
466 swap(fixture_hash_, other.fixture_hash_);
467 swap(fold_, other.fold_);
468 }
469 static MemoryKey memory_key(std::string_view first, std::optional<std::string_view> second) {
470 return {std::string(first), second ? std::optional<std::string>{std::string(*second)}
471 : std::nullopt};
472 }
473 static std::uint64_t key_hash(std::string_view first,
474 std::optional<std::string_view> second) noexcept {
475 std::uint64_t hash = 1469598103934665603ULL;
476 const auto byte = [&hash](unsigned char value) {
477 hash ^= value;
478 hash *= 1099511628211ULL;
479 };
480 const auto string = [&byte](std::string_view value) {
481 const auto length = static_cast<std::uint64_t>(value.size());
482 for (unsigned shift = 0; shift < 64; shift += 8)
483 byte(static_cast<unsigned char>(length >> shift));
484 for (const unsigned char value_byte : value) byte(value_byte);
485 };
486 byte(second ? 1 : 0);
487 string(first);
488 if (second) string(*second);
489 return hash;
490 }
491 std::uint64_t hash_for_key(std::string_view first,
492 std::optional<std::string_view> second) const noexcept {
493 return fixture_hash_ ? *fixture_hash_ : key_hash(first, second);
494 }
495 void read_data(std::uint64_t offset, void* destination, std::size_t length) const {
496 if (slab_) slab_->read_at(data_resource_, offset, destination, length);
497 else data_.read(offset, destination, length);
498 }
499 void write_data(std::uint64_t offset, const void* source, std::size_t length) {
500 if (slab_) slab_->write_at(data_resource_, offset, source, length);
501 else data_.write(offset, source, length);
502 }
503 void read_index(std::uint64_t offset, void* destination, std::size_t length) const {
504 if constexpr (MaintainSortedFold) {
505 if (slab_) {
506 Slot slot;
507 slot.offset_plus_one = fold_.bounded.read_slot(*slab_, index_resource_, offset / sizeof(Slot));
508 if (slot.offset_plus_one != 0)
509 read_data(slot.offset_plus_one - 1, &slot.hash, sizeof(slot.hash));
510 std::memcpy(destination, &slot, length);
511 return;
512 }
513 }
514 if (slab_) slab_->read_at(index_resource_, offset, destination, length);
515 else index_.read(offset, destination, length);
516 }
517 void write_index(std::uint64_t offset, const void* source, std::size_t length) {
518 if constexpr (MaintainSortedFold) {
519 if (slab_) {
520 Slot slot;
521 std::memcpy(&slot, source, length);
522 fold_.bounded.write_slot(*slab_, index_resource_, offset / sizeof(Slot), slot.offset_plus_one);
523 return;
524 }
525 }
526 if (slab_) slab_->write_at(index_resource_, offset, source, length);
527 else index_.write(offset, source, length);
528 }
529 Header read_header(std::uint64_t offset) const {
530 if (offset > data_bytes_ || data_bytes_ - offset < sizeof(Header))
531 throw IdPairStoreError(EIO, "id table truncated record");
532 Header header;
533 if constexpr (MaintainSortedFold) {
534 if (fold_.pending && offset == fold_.pending_offset) header = fold_.pending_header;
535 else read_data(offset, &header, sizeof(header));
536 } else {
537 read_data(offset, &header, sizeof(header));
538 }
539 const auto available = data_bytes_ - offset - sizeof(Header);
540 if (header.first_size > available || header.second_size > available - header.first_size
541 || (header.flags & ~(kLive | kPair)) != 0
542 || ((header.flags & kPair) == 0 && header.second_size != 0))
543 throw IdPairStoreError(EIO, "id table invalid record");
544 return header;
545 }
546 bool equal_string(std::uint64_t offset, std::uint64_t length, std::string_view key) const {
547 if (length != key.size()) return false;
548 std::array<char, 512> buffer{};
549 for (std::size_t at = 0; at < key.size();) {
550 const auto count = std::min(buffer.size(), key.size() - at);
551 read_data(offset + at, buffer.data(), count);
552 if (key.compare(at, count, buffer.data(), count) != 0) return false;
553 at += count;
554 }
555 return true;
556 }
557 // The first empty slot or the exact key's slot. Dead records retain
558 // their slot, so erasing cannot break another key's probe chain.
559 std::pair<std::uint64_t, Slot> locate(std::string_view first,
560 std::optional<std::string_view> second, std::uint64_t hash) const {
561 return locate_hash(hash, [&](std::uint64_t offset, const Header& header) {
562 return ((header.flags & kPair) != 0) == second.has_value()
563 && equal_string(offset + sizeof(Header), header.first_size, first)
564 && (!second || equal_string(offset + sizeof(Header) + header.first_size,
565 header.second_size, *second));
566 });
567 }
568 template<class Equal>
569 std::pair<std::uint64_t, Slot> locate_hash(std::uint64_t hash, const Equal& equal) const {
570 std::uint64_t at = hash & (slots_ - 1);
571 for (std::uint64_t probes = 0; probes < slots_; ++probes) {
572 Slot slot;
573 if constexpr (MaintainSortedFold) {
574 if (fold_.slot_pending && at == fold_.pending_at) slot = fold_.pending_slot;
575 else read_index(at * sizeof(Slot), &slot, sizeof(slot));
576 } else {
577 read_index(at * sizeof(Slot), &slot, sizeof(slot));
578 }
579 if (slot.offset_plus_one == 0) return {at, slot};
580 if (slot.hash == hash) {
581 const auto offset = slot.offset_plus_one - 1;
582 const Header header = read_header(offset);
583 if (header.hash != slot.hash)
584 throw IdPairStoreError(EIO, "id table mismatched index");
585 if (equal(offset, header)) return {at, slot};
586 }
587 at = (at + 1) & (slots_ - 1);
588 }
589 throw IdPairStoreError(EIO, "id table full index");
590 }
591 static void place_slot(IdPairScratchFile& index, std::uint64_t slots, const Slot& slot) {
592 std::uint64_t at = slot.hash & (slots - 1);
593 for (std::uint64_t probes = 0; probes < slots; ++probes) {
594 Slot existing;
595 index.read(at * sizeof(Slot), &existing, sizeof(existing));
596 if (existing.offset_plus_one == 0) {
597 index.write(at * sizeof(Slot), &slot, sizeof(slot));
598 return;
599 }
600 at = (at + 1) & (slots - 1);
601 }
602 throw IdPairStoreError(EIO, "id table rehash overflow");
603 }
604 void grow_index() {
605 constexpr auto maximum = static_cast<std::uint64_t>(std::numeric_limits<off_t>::max())
606 / sizeof(Slot);
607 if (slots_ > maximum / 2) throw IdPairStoreError(EFBIG, "id table index exhausted");
608 const auto next_slots = slots_ == 0 ? kInitialSlots : slots_ * 2;
609 IdPairScratchFile next(MaintainSortedFold
611 next.fixture_count_io(index_.fixture_io());
612 next.open(directory_);
613 next.resize(next_slots * sizeof(Slot)); // fresh sparse bytes read as zero
614 std::array<Slot, 128> buffer{};
615 for (std::uint64_t at = 0; at < slots_;) {
616 const auto count = std::min<std::uint64_t>(buffer.size(), slots_ - at);
617 index_.read(at * sizeof(Slot), buffer.data(),
618 static_cast<std::size_t>(count) * sizeof(Slot));
619 for (std::size_t i = 0; i < static_cast<std::size_t>(count); ++i)
620 if (buffer[i].offset_plus_one != 0) place_slot(next, next_slots, buffer[i]);
621 at += count;
622 }
623 index_ = std::move(next); // publish only a complete replacement
624 slots_ = next_slots;
625 }
626 std::optional<Value> lookup_key(std::string_view first,
627 std::optional<std::string_view> second) const {
628 if (memory_) {
629 const auto found = memory_->find(memory_key(first, second));
630 return found == memory_->end() ? std::nullopt : std::optional<Value>{found->second};
631 }
632 if (slots_ == 0) return std::nullopt;
633 const auto found = locate(first, second, hash_for_key(first, second)).second;
634 if (found.offset_plus_one == 0) return std::nullopt;
635 const Header header = read_header(found.offset_plus_one - 1);
636 return (header.flags & kLive) != 0 ? std::optional<Value>{header.value} : std::nullopt;
637 }
638 bool assign_key(std::string_view first, std::optional<std::string_view> second, Value value) {
639 if (memory_) {
640 const auto inserted = memory_->insert_or_assign(memory_key(first, second), value);
641 if (inserted.second) ++size_;
642 return inserted.second;
643 }
644 // Bounded admission precedes disk growth, record writes and counters.
645 preflight_assign(first, second);
646 if constexpr (MaintainSortedFold) flush_pending();
647 const auto hash = hash_for_key(first, second);
648 if (!slab_ && slots_ == 0) grow_index();
649 auto found = locate(first, second, hash);
650 if (found.second.offset_plus_one != 0) {
651 const auto offset = found.second.offset_plus_one - 1;
652 Header header = read_header(offset);
653 const bool inserted = (header.flags & kLive) == 0;
654 header.value = value;
655 header.flags |= kLive;
656 if constexpr (MaintainSortedFold) publish_fold(first, offset, header);
657 else write_data(offset, &header, sizeof(header));
658 if (inserted) ++size_;
659 return inserted;
660 }
661 if (used_ >= slots_ / 2) {
662 if (slab_) throw ScratchCapacityError(index_resource_,
663 scratch_count_limit_message(index_resource_, std::min(key_limit_, slots_ / 2)));
664 grow_index();
665 found = locate(first, second, hash);
666 }
667 if (size_ == std::numeric_limits<std::size_t>::max())
668 throw IdPairStoreError(EFBIG, "id table size exhausted");
669 const auto second_size = second ? second->size() : 0;
670 constexpr auto maximum = static_cast<std::uint64_t>(std::numeric_limits<off_t>::max());
671 if (data_bytes_ > maximum - sizeof(Header)
672 || first.size() > maximum - data_bytes_ - sizeof(Header)
673 || second_size > maximum - data_bytes_ - sizeof(Header) - first.size())
674 throw IdPairStoreError(EFBIG, "id table data exhausted");
675 Header header{hash, static_cast<std::uint64_t>(first.size()),
676 static_cast<std::uint64_t>(second_size), value, kLive | (second ? kPair : 0)};
677 if (!slab_) data_.open(directory_);
678 write_data(data_bytes_, &header, sizeof(header));
679 write_data(data_bytes_ + sizeof(Header), first.data(), first.size());
680 if (second)
681 write_data(data_bytes_ + sizeof(Header) + first.size(), second->data(), second->size());
682 const Slot slot{hash, data_bytes_ + 1};
683 if constexpr (MaintainSortedFold) publish_fold(first, data_bytes_, header, found.first, slot);
684 else write_index(found.first * sizeof(Slot), &slot, sizeof(slot));
685 data_bytes_ += sizeof(Header) + first.size() + second_size;
686 ++used_;
687 ++size_;
688 return true;
689 }
690 bool erase_key(std::string_view first, std::optional<std::string_view> second) {
691 if (memory_) {
692 const auto count = memory_->erase(memory_key(first, second));
693 size_ -= count;
694 return count != 0;
695 }
696 if constexpr (MaintainSortedFold) flush_pending();
697 if (slots_ == 0) return false;
698 const auto found = locate(first, second, hash_for_key(first, second)).second;
699 if (found.offset_plus_one == 0) return false;
700 const auto offset = found.offset_plus_one - 1;
701 Header header = read_header(offset);
702 if ((header.flags & kLive) == 0) return false;
703 header.flags &= ~kLive;
704 if constexpr (MaintainSortedFold) publish_fold(first, offset, header);
705 else write_data(offset, &header, sizeof(header));
706 --size_;
707 return true;
708 }
709
710 // Exactly one committed header and (on append) index slot may be pending
711 // in fixed RAM. Every read consults them. Flush BOTH before discarding
712 // either overlay: a partial header/slot write cannot expose mixed state.
713 // This also avoids relying on pwrite atomicity or a fallible rollback
714 // after the ordered root has been published.
715 void flush_pending() {
716 if (!fold_.pending) return;
717 write_data(fold_.pending_offset, &fold_.pending_header, sizeof(Header));
718 if (fold_.slot_pending)
719 write_index(fold_.pending_at * sizeof(Slot), &fold_.pending_slot, sizeof(Slot));
720 fold_.pending = fold_.slot_pending = false;
721 }
722
723 struct FoldReader {
724 const IdPairStore& owner;
725 std::uint64_t changed;
726 const Header& prospective;
727 std::array<unsigned char, 512> a{}, b{};
728
729 Header header(std::uint64_t row) const {
730 return row == changed ? prospective : owner.read_header(row);
731 }
732 unsigned symbol(std::uint64_t row, std::uint64_t at) const {
733 const auto h = header(row);
734 if (at >= h.first_size) return 0;
735 unsigned char byte = 0;
736 owner.read_data(row + sizeof(Header) + at, &byte, 1);
737 return static_cast<unsigned>(byte) + 1;
738 }
739 int compare(std::uint64_t row, std::string_view key) {
740 if (owner.fold_.fixture_work) ++owner.fold_.fixture_work->row_comparisons;
741 const auto h = header(row);
742 const auto common = std::min<std::uint64_t>(h.first_size, key.size());
743 for (std::uint64_t at = 0; at < common;) {
744 const auto n = static_cast<std::size_t>(std::min<std::uint64_t>(a.size(), common - at));
745 owner.read_data(row + sizeof(Header) + at, a.data(), n);
746 const int order = std::char_traits<char>::compare(
747 reinterpret_cast<const char*>(a.data()), key.data() + at, n);
748 if (order != 0) return order;
749 at += n;
750 }
751 return h.first_size < key.size() ? -1 : (h.first_size > key.size() ? 1 : 0);
752 }
753 int compare_rows(std::uint64_t first, std::uint64_t second) {
754 if (owner.fold_.fixture_work) ++owner.fold_.fixture_work->row_comparisons;
755 const auto x = header(first), y = header(second);
756 const auto common = std::min(x.first_size, y.first_size);
757 for (std::uint64_t at = 0; at < common;) {
758 const auto n = static_cast<std::size_t>(std::min<std::uint64_t>(a.size(), common - at));
759 owner.read_data(first + sizeof(Header) + at, a.data(), n);
760 owner.read_data(second + sizeof(Header) + at, b.data(), n);
761 const int order = std::char_traits<char>::compare(
762 reinterpret_cast<const char*>(a.data()),
763 reinterpret_cast<const char*>(b.data()), n);
764 if (order != 0) return order;
765 at += n;
766 }
767 return x.first_size < y.first_size ? -1 : (x.first_size > y.first_size ? 1 : 0);
768 }
769 IdPairOrderedFold::Position difference(std::uint64_t row, std::string_view key) {
770 const auto h = header(row);
771 const auto common = std::min<std::uint64_t>(h.first_size, key.size());
772 for (std::uint64_t at = 0; at < common;) {
773 const auto n = static_cast<std::size_t>(std::min<std::uint64_t>(a.size(), common - at));
774 owner.read_data(row + sizeof(Header) + at, a.data(), n);
775 for (std::size_t i = 0; i < n; ++i) {
776 const auto byte = static_cast<unsigned char>(key[at + i]);
777 if (a[i] != byte)
778 return IdPairOrderedFold::differing_symbol(at + i, a[i] + 1U, byte + 1U);
779 }
780 at += n;
781 }
782 if (h.first_size == key.size()) throw std::logic_error("id fold duplicate row");
783 return IdPairOrderedFold::differing_symbol(common, symbol(row, common),
785 }
786 IdPairOrderedFold::Position difference(std::uint64_t first, std::uint64_t second) {
787 const auto x = header(first), y = header(second);
788 const auto common = std::min(x.first_size, y.first_size);
789 for (std::uint64_t at = 0; at < common;) {
790 const auto n = static_cast<std::size_t>(std::min<std::uint64_t>(a.size(), common - at));
791 owner.read_data(first + sizeof(Header) + at, a.data(), n);
792 owner.read_data(second + sizeof(Header) + at, b.data(), n);
793 for (std::size_t i = 0; i < n; ++i)
794 if (a[i] != b[i])
795 return IdPairOrderedFold::differing_symbol(at + i, a[i] + 1U, b[i] + 1U);
796 at += n;
797 }
798 if (x.first_size == y.first_size) throw std::logic_error("id fold duplicate row");
799 return IdPairOrderedFold::differing_symbol(common, symbol(first, common), symbol(second, common));
800 }
801 void bytes(BrokerStateByteFold& fold, const void* data, std::size_t size) {
802 if (owner.fold_.fixture_work)
803 owner.fold_.fixture_work->byte_steps += 257 * static_cast<std::uint64_t>(size);
804 fold.bytes(data, size);
805 }
806 void compose(BrokerStateByteFold& before, const BrokerStateByteFold& after) {
807 if (owner.fold_.fixture_work) ++owner.fold_.fixture_work->compositions;
808 before.then(after);
809 }
810 void append(std::uint64_t row, BrokerStateByteFold& fold) {
811 const auto h = header(row);
812 if ((h.flags & kLive) == 0) return;
813 // BrokerStateHashSink::s uses u(size), followed by the raw key;
814 // ::i uses the native int64 representation. No endian rewrite.
815 bytes(fold, &h.first_size, sizeof(h.first_size));
816 for (std::uint64_t at = 0; at < h.first_size;) {
817 const auto n = static_cast<std::size_t>(std::min<std::uint64_t>(a.size(), h.first_size - at));
818 owner.read_data(row + sizeof(Header) + at, a.data(), n);
819 bytes(fold, a.data(), n);
820 at += n;
821 }
822 const auto value = static_cast<std::int64_t>(h.value);
823 bytes(fold, &value, sizeof(value));
824 }
825 };
826 void publish_fold(std::string_view key, std::uint64_t offset, const Header& header) {
827 publish_fold(key, offset, header, 0, Slot{});
828 }
829 void publish_fold(std::string_view key, std::uint64_t offset, const Header& header,
830 std::uint64_t at, Slot slot) {
831 FoldReader reader{*this, offset, header};
832 if (slab_) {
833 fold_.bounded.update(*slab_, index_resource_, used_,
834 slot.offset_plus_one != 0, key, offset, reader);
835 // Admission already checked these exact spans; slab writes copy
836 // without file IO. No pending overlay is needed for this backend.
837 write_data(offset, &header, sizeof(header));
838 if (slot.offset_plus_one != 0) write_index(at * sizeof(Slot), &slot, sizeof(slot));
839 return;
840 }
841 fold_.ordered.update(key, offset, directory_, reader);
842 // The root and table commit together with no more IO or allocation.
843 fold_.pending_header = header; fold_.pending_offset = offset;
844 fold_.pending_slot = slot; fold_.pending_at = at;
845 fold_.pending = true; fold_.slot_pending = slot.offset_plus_one != 0;
846 }
847
848 IdPairScratchFile data_{MaintainSortedFold
850 IdPairScratchFile index_{MaintainSortedFold
852 std::string directory_;
853 std::uint64_t data_bytes_ = 0;
854 std::uint64_t slots_ = 0;
855 std::uint64_t used_ = 0; // distinct keys including erased keys
856 std::size_t size_ = 0; // live keys
857 std::unique_ptr<MemoryMap> memory_; // allocated only by the explicit fixture seam
858 std::shared_ptr<ScratchSlab> slab_;
861 std::uint64_t key_limit_ = 0;
862 std::uint64_t fixed_slots_ = 0;
863 std::optional<std::uint64_t> fixture_hash_; // collision oracle seam, physical only
864 std::conditional_t<MaintainSortedFold, FoldState, NoFold> fold_;
865};
866
867static_assert(sizeof(IdPairStore<std::int64_t, true>) + IdPairOrderedFold::workspace_bytes() + 8192 <= 32768,
868 "consumed-cycle store plus all fixed fold buffers <=32 KiB");
869
870} // namespace pineforge::detail
static Position differing_symbol(std::uint64_t at, unsigned a, unsigned b) noexcept
static unsigned symbol(std::string_view key, std::uint64_t at) noexcept
static constexpr std::size_t workspace_bytes() noexcept
bool operator!=(const const_iterator &other) const
bool operator==(const const_iterator &other) const
std::uint64_t disk_bytes() const noexcept
bool insert_or_assign(std::string_view id, std::string_view from_entry, Value value)
IdPairStore & operator=(IdPairStore &&other) noexcept
void fixture_count_io(IdPairScratchFile::FixtureIO *io) noexcept
void min_assign(std::string_view id, std::string_view from_entry, Value value)
void preflight_assign(std::string_view first, std::optional< std::string_view > second=std::nullopt) const
bool erase(std::string_view id, std::string_view from_entry)
Value at(const std::string &id) const
void min_assign(std::string_view id, Value value)
void preflight_joined_single(std::string_view first, char separator, std::string_view last) const
IdPairStore(IdPairStore &&other) noexcept
void set_directory(const std::string &directory)
std::optional< Value > lookup(std::string_view id, std::string_view from_entry) const
bool erase(std::string_view id)
void fold_sorted(BrokerStateHashSink &sink) const
IdPairStore(const IdPairStore &)=delete
std::size_t size() const noexcept
const_iterator begin() const
IdPairStore & operator=(const IdPairStore &)=delete
void fixture_force_hash_collisions(std::uint64_t hash)
void bind_scratch_tables(const std::shared_ptr< ScratchSlab > &slab, ScratchResource data, ScratchResource index)
void fixture_count_fold_work(FixtureFoldWork *work) noexcept
void preflight_keys(const Enumerate &enumerate) const
std::optional< Value > lookup(std::string_view id) const
bool insert_or_assign(std::string_view id, Value value)
std::uint64_t slots(std::uint64_t keys)
std::string scratch_count_limit_message(ScratchResource resource, std::uint64_t limit)
std::string scratch_byte_limit_message(ScratchResource resource, std::uint64_t limit)
std::optional< std::string > from_entry
std::optional< std::string_view > second