The portable file store keeps admitted records durably in plain
files, using only ordinary file reads, positioned writes, syncs and
directory operations. The same code runs on hosts and on a small
device’s FAT card through ESP-IDF’s VFS, holds no per-record state in
memory, receives and sends records of any valid size in bounded pieces,
and answers repeated queries from posting lists read a page at a time.
It implements the semantic store contract of store-record-fact-access.md;
this note owns its layout, commit order, index and recovery. It is not a
filesystem-shaped store profile and shares no root with one.
Lace::portable(root, PortableStoreFormat) in
lace-rt and lace-tokio opens or creates a root
and adopts position_may_advance_externally = false. The
format sets the segment size and the initial index size and is used only
when the root is created; reopening uses the values recorded in
format.
One Lace owns a root until close or final owner drop. A second opener
in the same process fails as Unavailable, by absolute root
path. Where the platform has file locks, the owner also holds a kernel
lock on lock, which excludes other processes and path
aliases; where it has none (ESP-IDF), one process is assumed to own the
card. Records the store returned stay readable after close: each retains
its segment’s shared open handle, never reopened by path. A
process-local weak registry by absolute segment path reuses that handle
after cache eviction or a new adoption. FatFs forbids a second open of a
file with a live writable handle. The registry retains no descriptor;
its entry is removed only after the last owner’s descriptor closes. An
opener racing that final drop waits for its close before opening again.
This does not extend root ownership.
root/
format marker, segment bytes, initial index slots
lock lock rendezvous; holds no data
head0, head1 alternating committed heads
rows fixed 64-byte rows, one per admitted record
index hash index
seg00000 ... record bytes, one file per segment
postings.v2/ posting lists and their `dirty` marker
staging/ one subdirectory per adoption
Names in the root are 8.3-compatible, so FAT without long names holds
the records; FAT returns them in upper case and names read back compare
without case. Posting-list paths below postings.v2/ need
long names (ESP-IDF CONFIG_FATFS_LFN_HEAP); without them
every list write fails, which disables lists for the session, and
queries scan.
record-log row:
hash text, byte offset and byte length. The offset is global: segment
number × segment size + offset in the segment.
StoreRecordId(i) is row i − 1;
StorePosition is the committed row count.n
is written to head(n mod 2).format holds
lace-portable-file-store/1\n and the two sizes. Opening an
absent or empty root, or one holding only an incomplete
format, lock and empty head files, creates it:
both empty head files, then format, then one directory
sync. Any other root without a valid format fails as
CorruptStore.One StoreRecords call classifies duplicates against the
index and within the batch, then commits its new records:
rows;The synced head is the admission point. A failure before the head
write leaves the committed state unchanged; the next commit overwrites
the same bytes, rows and head file. A failed head write may still reach
the disk, so the store refuses further commits with
StoreFailed until it is reopened; reads continue at the
previous head. Duplicate admission returns the existing id and writes
nothing.
Open selects the valid head with the highest sequence; a head whose
checksum fails or that sits in the wrong file is invalid. A torn head
write leaves the previous head valid. With no valid head the store is
empty unless both head files were written, which no crash produces and
which fails as CorruptStore. Bytes and rows beyond the
chosen head are ignored and later overwritten. A rows file
shorter than its head fails. Committed bytes are trusted: loads check
the prefix’s glyph and Data-Length and the row’s hash, not
record hashes or Marks.
index is a 32-byte header and a power-of-two table of
8-byte slots: a row id in the low 40 bits and a 24-bit hash fingerprint.
Lookup probes linearly from the hash’s home slot, reading 64 slots at a
time, and confirms a fingerprint match against the row’s hash. The table
stays at most half full, so exact lookup and duplicate checks take an
expected one slot read and one row read.
Commits insert slots without syncing them. A checkpoint at open,
close and drop syncs the slots before the header records how many rows
they cover, so a covered row is always findable. Open inserts any rows
past the covered count again, which is a no-op for slots already
written. A header that is invalid or claims more rows than the head, or
a table about to pass half full, is rebuilt by streaming
rows into a fresh table of doubled size: the header is
invalidated durably before slots are cleared.
FindRecords runs through the shared planner, executor
and matcher. An exact hash uses the index. Other queries use the posting
lists of their keys that exist, intersected in increasing id order with
the shared traversal (the shortest bounded list drives); with none they
scan ids in their bounds. Every candidate costs its row and its record
prefix in at most two bounded reads (1 KiB, then up to the maximum
prefix). A loaded record is a file-range record over its segment.
Lists use the native filesystem store’s primitive keys,
.ids paths and file format below postings.v2/:
matching ids as little-endian u64 in increasing order, then a sentinel
position through which every record was examined. Which keys get lists
follows the native heat window and known-permanent selection. The store
keeps no ids in memory, only each hot list’s id count and positions:
So a warm query reads its candidates’ records, not every record, and a commit costs one write per hot list it matches.
Lists are acceleration, not committed state, and their writes are not
synced as they happen. Before its first list write a session creates
postings.v2/dirty. Close and drop write the hot lists’
sentinels, sync every list written, then remove dirty. Open
removes every list when dirty exists, since a crash may
have torn any of them, removing dirty last. A list that is
malformed at open (length, sentinel above the head, more ids than its
sentinel) or missing is rebuilt; one found malformed while a query reads
it is removed, and the query scans the rest of its range. A failed list
write disables lists for the session and leaves dirty.
Record truth that cannot be read during list work fails the
operation.
The directory name is the index generation, as for the filesystem-shaped
stores: a change to the projected 020 record facts names a new one,
and open removes postings and every other
postings.* directory, comparing names without case. The
format marker changes only with record bytes or layout.
The store supplies staging under staging/ for connected
reception, as runtime-provider-architecture.md
defines. Each adoption stages into its own subdirectory, registered as
live in the process while the staging handle or any staged record
exists. Opening removes every other entry: residue of earlier processes,
and of earlier adoptions whose records are gone. A file a live record
may hold is never removed, which FAT requires.
CapacityExceeded.rows, index and staging
files.