Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ The engine has no dependencies outside `java.base`. All three modules build into
|---|---|
| `io.hstore.engine` | `StorageEngine` (open/close, maintenance thread, checkpoint/compact entry points), `EngineOptions`, `EngineStats`, `Dictionary` (interned role names and role sets), `HStoreException` (error codes, retryable conflicts). |
| `engine.page` | Byte-level storage. `PageStore` and `SegmentFile` hold segment files and allocation. `PageHeader` is the 80-byte header and crc32c seal. `PageId` is a page's permanent number, `PageAddress` its place in a segment, and `PageDirectory` the memory-mapped `pages.dir` that maps one to the other. `ReferenceCounts` is `pages.ref`, the count of references to each page, with its checkpoint journal. `ByteCursor` provides little-endian and varint codecs over `MemorySegment`. `IoTrace` counts page visits per query through a `ScopedValue`. |
| `engine.tree` | The copy-on-write counted B+tree. `Tree` is the immutable handle and `TreeWriter` does path-copy updates. Nodes are `Leaf` and `Branch`, referenced by `Ref` (`Empty`, `Pending` or `Stored`). Also here: `Summary` (monoid annotations), `TreeCursor`, `TreeAlgebra` (leapfrog, set algebra), `TreeDiff`, `BulkBuilder`, `NodeCodec`, `NodeCache`, `Materializer`, `TreeWalker` and `TreeVerifier`. |
| `engine.tree` | The copy-on-write counted B+tree. `Tree` is the immutable handle and `TreeWriter` does path-copy updates. Nodes are `Leaf` and `Branch`, referenced by `Ref` (`Empty`, `Pending`, `Stored`, or `Inline` for a small leaf carried inside an edge record). Also here: `Summary` (monoid annotations), `TreeCursor`, `TreeAlgebra` (leapfrog, set algebra), `TreeDiff`, `BulkBuilder`, `NodeCodec`, `NodeCache`, `Materializer`, `TreeWalker` and `TreeVerifier`. |
| `engine.topology` | Hyperedge representation: `Hyperedge` (members tree plus order index), `Incidence` (one member's role set, weight, validity, payload and qualifier), `IncidenceCodec` (columnar leaf encoding), `MemberChange`, `Weight` (fixed point) and `Validity`. |
| `engine.index` | `PostingIndex`, a two-level index from a key to a postings set. Small sets are inlined and large ones are promoted to their own tree. Used by the reverse incidence, type, canonical-key and symbol indexes. |
| `engine.catalog` | `AtomRecord` (`NodeRecord`/`EdgeRecord`) and its codec, `EngineSlots` (built-in slots, schemas and key functions), `Slot`/`SlotRegistry`, `RootVector`, `Generation`, `Branch`, `Symbol`, and `CatalogImage`/`CatalogStore` (checkpoint files). |
Expand Down Expand Up @@ -229,25 +229,25 @@ Readers take no locks at all.
The following is real output from `hstore init` followed by loading `examples/clinical-claims.hql` and running `CHECKPOINT;` (default 16 KiB pages):

```text
FORMAT 55 B format=5, page-size=16384 (java.util.Properties)
FORMAT 55 B format=6, page-size=16384 (java.util.Properties)
LOCK 0 B FileChannel.tryLock() held while open
hstore.conf 2863 B server configuration template written by `hstore init`
hstore.conf 2903 B server configuration template written by `hstore init`
catalog/manifest 20 B name of the latest catalog file
catalog/generations/0000000000000000.cat 37 B checkpoint of generation 0
catalog/generations/000000000000009d.cat 49851 B checkpoint of generation 157 (current + retained history)
data/segments/00000001.seg 1213651 B segment 1: packed node images
catalog/generations/000000000000009d.cat 50554 B checkpoint of generation 157 (current + retained history)
data/segments/00000001.seg 1029664 B segment 1: packed node images
data/segments/pages.dir 8388608 B page directory, one 8 MiB chunk mapped so far (sparse)
data/segments/pages.ref 8388608 B reference counts, same layout (sparse)
data/segments/pages.ref.journal 0 B emptied after every checkpoint
wal/segments/0000000000000000.wal 165595 B WAL segment starting at LSN 0
feed/0000000000000000.feed 29846 B change feed segment starting at byte 0
wal/segments/0000000000000000.wal 161389 B WAL segment starting at LSN 0
feed/0000000000000000.feed 28782 B change feed segment starting at byte 0
payload/000001.pay 0 B payload segment (database layer)
semantic/state 53 B persisted HNSW index state (database layer)
```

| Path | Owner | Format |
|---|---|---|
| `FORMAT` | `StorageEngine.verifyFormat` | `Properties` with `format=5` (page numbers resolved through a page directory, reference counts per page) and `page-size` (the maximum node size). Opening a directory written in an older format, or opening with a different page size, fails. |
| `FORMAT` | `StorageEngine.verifyFormat` | `Properties` with `format=6` (page numbers resolved through a page directory, reference counts per page, small member trees inline in their edge record) and `page-size` (the maximum node size). Opening a directory written in an older format, or opening with a different page size, fails. |
| `LOCK` | `StorageEngine.open` | An exclusive OS file lock. A second process gets `database ... is opened by another process`. |
| `catalog/generations/%016x.cat` | `CatalogStore.publish` | `int magic 0x54414348`, `int crc32c(payload)`, `long length`, then the `CatalogImage` payload. Written to `*.tmp`, fsynced, atomically renamed, and the directory fsynced. The three newest files are kept. |
| `catalog/manifest` | `CatalogStore` | The file name of the latest `.cat`, written durably the same way. |
Expand All @@ -260,7 +260,7 @@ semantic/state 53 B persisted HNSW index state (datab
| `semantic/state`, `semantic/hnsw-<model>.idx` | `db.semantic.SemanticPlane` | A `Properties` file with `generation` and `models`, plus one serialized HNSW graph per model, written atomically at checkpoints and on close. |
| `hstore.conf` | `server.ServerConfig` | Settings, overridden by `HSTORE_*` environment variables and then by `--setting` flags. |

Every hyperedge's membership is a separate nested tree; `hstore check` reports 85 nested trees under the `catalog` slot. Most of them are a single small leaf of 100–200 bytes. Segments pack node images at 64-byte alignment instead of reserving a full `page_size` slot per node, so the whole example occupies 1.2 MB of segment space. With fixed 16 KiB slots it took 17.6 MB. See [pages.md](../storage/pages.md#segment-files).
Every hyperedge's membership is a nested tree; `hstore check` reports 85 nested trees under the `catalog` slot. All of them are small enough to be kept inline in their edge record, so the whole catalog, edges and members included, is one page. Segments pack node images at 64-byte alignment instead of reserving a full `page_size` slot per node, so the whole example occupies 1.0 MB of segment space, most of it older images still kept for history. With fixed 16 KiB slots it took 17.6 MB. See [pages.md](../storage/pages.md#segment-files).

## Verifying a directory

Expand All @@ -280,6 +280,6 @@ branch main
property-index 162 entries 9 pages 8 nested trees height 1
...
reverse 53 entries 1 pages 0 nested trees height 1
catalog 458 entries 86 pages 85 nested trees height 1
verified 105 pages: checksums, ordering, balance and summaries are consistent
catalog 458 entries 1 pages 85 nested trees height 1
verified 20 pages: checksums, ordering, balance and summaries are consistent
```
34 changes: 33 additions & 1 deletion docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ not what drives HStore's footprint:
record carries roles, weight, validity, position and qualifier. The cost buys per-member metadata and
snapshot-consistent reads, which a plain link cannot express. The benchmark now reads ids through
`Reader.forEachMemberId`, which builds neither `Member` records nor tree entries; the numbers below predate that.
What remains is that every hyperedge keeps its members in a tree of its own, one more page to read.
Small hyperedges now keep their members inline in their catalog record, so the extra page per edge is gone
too (see *Inline member trees* below); the tables above predate that as well.
* **Edge ingest at small batch sizes (about 0.6–0.8× in async mode).** Each commit rewrites the incident-tree
paths of every touched member, which is copy-on-write write amplification. JE appends log records and
defers the work to its cleaner. In `sync` mode group commit amortises the fsync and HStore leads (1.35×).
Expand All @@ -263,6 +264,37 @@ not what drives HStore's footprint:
([maintenance.md](storage/maintenance.md)). Raising `compaction_live_ratio` trades write amplification for
space.

### Inline member trees

Storage format 6 keeps a hyperedge's member tree inside its catalog record when it is a single leaf of at most 32
members that fits in about 100 bytes ([persistent-tree.md](storage/persistent-tree.md#inline-trees)), and member
leaves without per-member columns no longer hold an `Incidence` object per member. The table compares HStore
before and after those two changes, alone, at scale 1 with `async` durability and `CACHE_MB=64`: three runs of
each build, alternated, median shown. The machine was busy with other work during these runs, so the
throughput column moves around by a lot from run to run. Allocation, bytes read and disk are steady and are the
numbers to trust.

| Workload | Before | After | Allocated per op | Read from storage per op |
|---|---:|---:|---:|---:|
| `read.members` | 615,661 ops/s | 726,507 ops/s | 979 B → 493 B | 73 B → 0 B |
| `read.twohop` | 63,817 ops/s | 90,044 ops/s | 8,760 B → 5,947 B | 337 B → 0 B |
| `large.scan` | 799 M ops/s | 687 M ops/s | 0 B → 0 B | 0 B → 0 B |
| `large.probe` | 2.00 M ops/s | 1.72 M ops/s | 168 B → 232 B | 0 B → 2 B |
| `ingest.edges` | 16,115 ops/s | 15,941 ops/s | 58.1 KB → 34.9 KB | |
| `churn.remove` | 8,589 ops/s | 12,425 ops/s | 61.0 KB → 47.4 KB | |

| Size | Before | After |
|---|---:|---:|
| `disk` after load | 197.2 MiB | 145.4 MiB |
| `disk.churned` | 430.3 MiB | 296.5 MiB |
| `disk.compacted` | 201.6 MiB | 66.1 MiB |
| bytes written by `ingest.edges` | 152.5 MiB | 127.8 MiB |
| heap retained after ingest | 53.7 MiB | 39.1 MiB |

Member reads no longer touch a page of their own, which removes the reads that missed the 64 MiB cache. The one
cost is in `large.probe`, which looks up a single member of the 50,000 member edge: its `Incidence` is now built
when it is read instead of being kept in the cached leaf, about 64 bytes per lookup.

## Reproducing

```
Expand Down
8 changes: 7 additions & 1 deletion docs/database/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ record EdgeRecord(int type, EdgeKind kind, Ref members, Ref order, long version,
|---|---|
| `type` | id of the `TypeDef` in the schema slot `TYPES` |
| `canonicalKey` | optional, at most `NodeRecord.MAX_KEY_LENGTH` = 512 characters; unique per `(tenant, type)` |
| `members` / `order` | roots of the membership tree and (for ordered edges) the order tree, see [topology](../storage/topology.md) |
| `members` / `order` | roots of the membership tree and (for ordered edges) the order tree, see [topology](../storage/topology.md). A small tree (at most 32 members that fit in about 100 bytes) is kept inside the record itself, so reading a typical edge needs no page beyond the catalog leaf |
| `version` | incremented on every `withRoots` (membership change) |
| `embeddingRef` | payload reference of the node's current embedding vector (set by `Writer.embed`) |
| `tenant` | owning tenant; enforced by every `Reader` (see [security](security.md)) |
Expand Down Expand Up @@ -306,6 +306,12 @@ yields `Incident(edge, roles, locator)`; `Reader.position(incident)` converts th
position. Membership trees, their summaries (role bitmaps, weight min/max/sum, validity bounds) and the reverse
index are described in [topology](../storage/topology.md).

How a membership is stored is invisible through the API. A small edge's members live inline in its catalog record,
and a larger one's in a tree of its own pages; an edge moves between the two as it grows and shrinks, and reads,
history, the change feed and conflict checks behave the same either way. A member with no roles, weight, validity,
payload or qualifier costs no `Incidence` object while it sits in the node cache. The record is built when it is
read.

`MemberSpec` (`MemberSpec.java`) carries the per-member options on writes:
`MemberSpec.roles("host").withWeight(0.75).withValidity(new Validity(0, 1_000))`.

Expand Down
4 changes: 2 additions & 2 deletions docs/database/evidence-and-temporal.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ MATCH NODE p:Patient WHERE p.name = 'Asha Rao'
`Temporal.diff(before, after)` compares two readers (two generations or two branches):

1. `TreeDiff.diff` over the two catalog trees yields added, removed and changed atom records; structural sharing
between copy-on-write trees lets the diff skip shared subtrees in `O(1)` (`Ref.same`: same stored page id or
same in-memory node), and subtrees with disjoint key ranges are expanded on one side only
between copy-on-write trees lets the diff skip shared subtrees in `O(1)` (`Ref.same`: same stored page id,
same in-memory node, or equal entries for a small inline tree), and subtrees with disjoint key ranges are expanded on one side only
([persistent tree](../storage/persistent-tree.md)).
2. Added atoms become `AtomCreated` plus, for edges, a `MembersChanged` delta against an empty edge.
3. Removed atoms become `AtomDeleted`.
Expand Down
9 changes: 5 additions & 4 deletions docs/operations/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,13 @@ checks page checksums, key ordering, node balance, monoid summaries and nested t

```
$ hstore check /srv/hstore
generation 158, recovered 0 commits from the log
generation 157, recovered 0 commits from the log
branch main
property-index 162 entries 9 pages 8 nested trees height 1
catalog 458 entries 1 pages 85 nested trees height 1
…
catalog 458 entries 88 pages 85 nested trees height 2
verified 110 pages: checksums, ordering, balance and summaries are consistent
property-index 162 entries 9 pages 8 nested trees height 1
verified 20 pages: checksums, ordering, balance and summaries are consistent
reference counts match the trees: 20 pages in use, 423 waiting for older readers
```

A violated invariant aborts the check with the offending page and a reason such as
Expand Down
4 changes: 2 additions & 2 deletions docs/operations/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,8 @@ skipped because `FORMAT` exists. To verify a backup, run
`docker run --rm -v restored:/var/lib/hstore/data hstore check /var/lib/hstore/data`.

To upgrade, stop the container, back up the volume, and start the new image on the same volume. `FORMAT`
records the on-disk format version (`format=5`, reference counts) and the page size. Both are checked at
records the on-disk format version (`format=6`, inline member trees) and the page size. Both are checked at
open. A directory written in another format is refused with
`database … uses storage format N; this build reads format 5 (reference counts); export and reload it`,
`database … uses storage format N; this build reads format 6 (inline member trees); export and reload it`,
so the server never misreads it. Export the data with HQL against the old version and reload it into a fresh
volume.
2 changes: 1 addition & 1 deletion docs/operations/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ and in Docker it is the last line of `docker logs`. The storage-integrity failur

| Message | Meaning |
|---|---|
| `database <dir> uses storage format N; this build reads format 5 (reference counts); export and reload it` | `FORMAT` was written by an incompatible build. |
| `database <dir> uses storage format N; this build reads format 6 (inline member trees); export and reload it` | `FORMAT` was written by an incompatible build. |
| `database uses N byte pages, options request M` | `page_size` differs from the value fixed at `init`. |
| `write-ahead log segment <file> is damaged (<reason>) but later segments exist; replay would silently drop committed transactions` | `CORRUPT_LOG`: damage in a non-tail WAL segment. A torn record at the end of the *last* segment is normal after a crash. Replay simply ends there. Damage before later segments means storage corruption, so recovery refuses to guess. Restore from backup. |

Expand Down
4 changes: 2 additions & 2 deletions docs/storage/catalog-and-generations.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ MERGE BRANCH what_if INTO main ON CONFLICT FAIL;
```text
Generation := id | wallTime | txnId | nextAtom | nextTxn | branchCount | Branch*
Branch := id | name | parent | baseGeneration | createdAt | u8 state | rootCount | (slot | Ref)*
Ref := i64le pageId | Summary (pageId 0 ⇒ empty, no summary)
Ref := u8 0 (empty) | u8 1 | i64le pageId | varint units | Summary (slot roots are never inline)
CatalogImage:= Generation(current) | historyCount | Generation* | segmentCount
| (segmentId | u8 state | pages | retiredAt)* | checkpointLsn | feedEnd
```
Expand Down Expand Up @@ -185,7 +185,7 @@ varint slotCount | slotChange*
| [before value] | [after value] (the slot schema's own ValueCodec)
```

Values whose codec holds references are **detached** before encoding: every nested `Ref` is replaced with `Ref.EMPTY`. An `EdgeRecord` in the feed therefore carries its type, kind and version but not page pointers, which would be meaningless to a consumer and invalid after compaction. Topology is conveyed by the `members` list instead.
Values whose codec holds references are **detached** before encoding: every nested `Ref` is replaced with `Ref.EMPTY`, inline ones included. An `EdgeRecord` in the feed therefore carries its type, kind and version but not page pointers, which would be meaningless to a consumer and invalid after compaction, nor a copy of a small edge's members. Topology is conveyed by the `members` list instead.

### File format

Expand Down
2 changes: 1 addition & 1 deletion docs/storage/change-feed.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ for each flagged column, in that order:

A column is flagged only when it differs from the default (`roleSet != NO_ROLES`, `weight != Weight.ONE`, `validFrom != Long.MIN_VALUE`, `validTo != Long.MAX_VALUE`, `dataRef != 0`, `qualifier != 0`), so a plain membership costs 1 byte of flags plus the member id.

Slot values that contain tree references (codecs with `holdsRefs()`, for example an `EdgeRecord` with member and order roots) are **detached** before encoding: every embedded `Ref` is replaced with `Ref.EMPTY`. Feed events therefore never pin pages, and compaction may move or reclaim any page without invalidating the feed. Consumers that need the topology of an edge read it from a snapshot, or use the event's `members` list.
Slot values that contain tree references (codecs with `holdsRefs()`, for example an `EdgeRecord` with member and order roots) are **detached** before encoding: every embedded `Ref` is replaced with `Ref.EMPTY`, including a small edge's inline member tree. Feed events therefore never pin pages, and compaction may move or reclaim any page without invalidating the feed. Consumers that need the topology of an edge read it from a snapshot, or use the event's `members` list.

### Example

Expand Down
Loading
Loading