count

package
v0.14.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 15, 2026 License: MIT Imports: 4 Imported by: 0

Documentation

Overview

Package count holds the derived, non-durable relationship count-store that backs exact cardinality estimates for the Cypher planner (design docs/count-store-design.md, task #2082). It maintains three relationship statistics keyed by the stable interned ids of the graph's single label/relationship-type registry:

E(relType)            — live edges of a relationship type
D(label, relType, dir)— degree-sum: edge endpoints of relType in a direction
                        whose this-end node carries label
T(labelA, relType, labelB) — live edges (:labelA)-[:relType]->(:labelB)

The node statistic N(label) is NOT stored here; it is read from the existing label index (see cypher/api.go ResolveLabelCount).

Structure

Each cell is an atomic.Int64 held in one of a fixed number of shards; a cell is created on first observation of a combination and DELETED when its counter returns to zero, so the store's footprint is bounded by the number of currently-observed schema combinations — a function of schema cardinality, never of |V| or |E| (design §2.3). Keys are the registry's uint32 ids, so no string touches the hot path.

Within a shard each family's cell map is IMMUTABLE ONCE PUBLISHED and swapped as a whole through an atomic.Pointer (copy-on-write). Only the map's STRUCTURE — which combinations exist — is versioned that way; a cell's VALUE lives behind the pointer the map holds and changes in place, so the ordinary increment never rebuilds a map. See [addCell] for why that split is what makes the store scale.

Concurrency contract

The Store is safe for concurrent use, and it no longer rests on any exclusion the engine provides. This contract used to say that all MUTATIONS were serialised by the engine's write barrier (visMu.Lock in commitUnderBarrier) and that all READS ran under a read barrier (visMu.RLock in Graph.View). BOTH HALVES ARE FALSE, and have been since sprint 334 made MVCC the module's concurrency control: commitUnderBarrier now runs inside a SHARED hold, so two writers mutate this store concurrently, and an ordinary query read takes no barrier at all — Graph.View survives only for DDL-adjacent scans.

What makes it safe is therefore the structure itself, not exclusion:

  • Store.CountE, Store.CountD and Store.CountT take NO LOCK AT ALL. They load the shard's published cell map through an atomic.Pointer and read the cell. The map they observe is immutable, so a concurrent structural change cannot mutate it under them;
  • an increment to an ALREADY-PRESENT cell runs under the shard's SHARED hold and is a single atomic.Int64.Add. The shared hold is not protecting the arithmetic — the arithmetic is already atomic. It is what stops a cell being unlinked out from under an in-flight increment, which would silently discard the delta. Since rmp #2696 that hold is taken on an [rbMutex], whose shared side is striped per-P, rather than on a sync.RWMutex whose shared side was one contended word;
  • creating a cell, and deleting one that has returned to zero, take the shard's EXCLUSIVE lock. Those are the only two operations that change a map's structure, and their frequency is schema-cardinality-bounded rather than data-size-bounded;
  • the aggregate is ORDER-INSENSITIVE (rmp #2303): a cell is deleted at exactly zero rather than at zero-or-below, so concurrent partial sums that transit a negative value do not lose a decrement. That property is what replaced writer exclusion, and [addCell] documents the failure it fixes.

The store spawns no goroutines.

Why the WRITE path's shared hold is striped (rmp #2696)

rmp #2682 removed the read lock and fixed the SPREAD case. It did not fix the single-hot-type case, which was unchanged at 0.319x from 1 to 8 goroutines, and the reason is that the remaining cost was never the counter.

MEASURED at HEAD 42a27558, one hot relationship type at 8 goroutines: sync/atomic.(*Int32).Add was 48.85% of ALL CPU, and pprof -peek attributes 100% of it to sync.RWMutex.RLock (53.17%) and sync.RWMutex.RUnlock (46.83%). The count cell those calls exist to protect — an atomic.Int64 — was 12.60%. The lock word was the contended object; the counter was not.

That distinction decided the design, and it was settled by measurement rather than by argument. Two candidates were built and benchmarked against the same 90%-read/10%-write shape:

  • a STRIPED COUNTER summed on read, in the shape of Java's LongAdder: each cell becomes eight per-line counters, a write picks one, a read sums them. It bought 1.227x, made an uncontended read 1.92x SLOWER (5.198ns against 2.702ns) and an uncontended write 3.4x slower, because the delete-on-zero test must sum every stripe on every increment. It loses, and it loses because it unshares the object that was not shared enough to matter;
  • a STRIPED LOCK with the counter left alone — the shape adopted here. It bought 2.018x on the same benchmark and left the read path exactly as it was, O(1) and exact, at 0.994x.

The generalisable lesson is that on this store the FREQUENT hold is the shared one and the RARE hold is the exclusive one, so the object worth striping is the LOCK. ClickHouse draws the same line explicitly between its two counter families: ProfileEvents is striped per-CPU and summed on read, while CurrentMetrics is a single unstriped atomic precisely because it must be exactly readable at a point in time (src/Common/CurrentMetrics.h). This store is the second kind — the planner reads an exact cardinality, and delete-on-zero needs an exact zero — so striping its counter was the wrong transplant.

The lock-free increment that does not work

Recorded because it is the obvious next idea and it is WRONG. Replacing the shared hold with a dead-flag handshake — the writer adds and then checks a flag, the unlinker sets the flag and then re-reads the counter — appears sound and is not. After a SUCCESSFUL unlink the flag stays set, so a writer whose delta was already counted into the zero that justified the unlink also observes it, undoes its delta and re-applies it to the replacement cell. The delta lands twice and the aggregate drifts. MEASURED: with the unlink ablated the same increment is exact over 8x50000 oscillations through zero, and with the unlink restored the same test read 13 against an expected 24. Safe reclamation of a cell that lock-free writers may still hold a pointer to needs epochs or hazard pointers, which is a far larger change than this store's contention warrants.

Why the read path holds no lock (rmp #2682)

It used to take the shard's read lock, and that single sync.RWMutex reader counter was measured to be the whole of the store's contention: with one hot relationship type — every cell of which lands on one shard, because [Store.eShardOf] keys on the relationship type alone — throughput FELL to 0.391x going from 1 to 8 goroutines on a 10-core host, and 98.15% of the module's mutex delay sat on this package's increment.

That is the same failure rmp #2203 measured elsewhere in this module: a bare sync.RWMutex degrading 17.6x from 1 to 10 cores purely because of its one shared reader counter, the counter and not the code around it being the bottleneck. A reader-side RLock is two atomic read-modify-writes on ONE cache line shared by every core, so it serialises at the cache-coherence level however little work the critical section does. Copy-on-write removes the read-side read-modify-write entirely: a reader performs a plain atomic LOAD, which leaves the line in shared state on every core at once.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Delta

type Delta struct {
	A     uint32    // KindD: label; KindT: labelA; KindE: unused.
	RT    uint32    // relationship-type id.
	B     uint32    // KindT: labelB; otherwise unused.
	Delta int64     // signed increment (+1 on create, -1 on remove).
	Kind  Kind      // which family this delta targets.
	Dir   Direction // KindD only.
}

Delta is one buffered increment to a single count cell. It is a small value carried by copy; a transaction accumulates a slice of them in the engine's CountBuffer and applies them at commit via Store.Apply.

func DDelta

func DDelta(label, rt uint32, dir Direction, sign int64) Delta

DDelta builds a D(label, relType, dir) increment.

func EDelta

func EDelta(rt uint32, sign int64) Delta

EDelta builds an E(relType) increment.

func TDelta

func TDelta(a, rt, b uint32, sign int64) Delta

TDelta builds a T(labelA, relType, labelB) increment.

type Direction

type Direction uint8

Direction selects which end of a relationship a [D] degree-sum counts.

const (
	// Out counts the source endpoint's label (n)-[rt]->().
	Out Direction = iota
	// In counts the destination endpoint's label ()-[rt]->(n).
	In
)

type DirtyMark

type DirtyMark struct {
	Label uint32
	Scope DirtyScope
}

DirtyMark records that a family becomes non-exact for one label id, buffered alongside deltas and applied at commit via Store.MarkDirty. See design §3.3.1: a relabel whose IN-side cannot be enumerated in O(delta) marks the minimal X-scoped IN cells dirty rather than writing a wrong exact.

type DirtyScope

type DirtyScope uint8

DirtyScope selects which X-scoped exactness set a DirtyMark toggles off.

const (
	// DirtyDOut marks D(label, *, OUT) untrustworthy for a label.
	DirtyDOut DirtyScope = iota
	// DirtyDIn marks D(label, *, IN) untrustworthy for a label.
	DirtyDIn
	// DirtyTA marks T(label, *, *) untrustworthy (the a-position).
	DirtyTA
	// DirtyTB marks T(*, *, label) untrustworthy (the b-position).
	DirtyTB
)

type Kind

type Kind uint8

Kind selects which count family a Delta targets.

const (
	// KindE targets E(relType); only RT and Delta are read.
	KindE Kind = iota
	// KindD targets D(label, relType, dir); A (the label), RT, Dir and Delta are read.
	KindD
	// KindT targets T(labelA, relType, labelB); A, RT, B and Delta are read.
	KindT
)

type Snapshot

type Snapshot struct {
	E         map[uint32]int64
	DOut      map[uint64]int64
	DIn       map[uint64]int64
	T         map[[3]uint32]int64
	DirtyDOut []uint32
	DirtyDIn  []uint32
	DirtyTA   []uint32
	DirtyTB   []uint32
}

Snapshot is a point-in-time copy of every live cell and dirty marking, for observability and differential testing. The D keys are dkey(label, relType) = label<<32|relType; the T keys are [3]uint32{labelA, relType, labelB}. The dirty slices list the label ids currently marked non-exact in each family.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the sharded relationship count-store. Its zero value is not usable; construct one with New.

A Store is SAFE FOR CONCURRENT USE by any number of goroutines, in every combination of its operations: Store.CountE, Store.CountD, Store.CountT, Store.DDirty and Store.TDirty are concurrent reads; Store.Apply, Store.MarkDirty and Store.RecomputeReset are concurrent mutations and need no serialisation from the caller; Store.Snapshot and Store.Cells are observability reads safe to take against a live workload. The package documentation gives the structural reason each of those holds.

func New

func New(maxRecountEdges int) *Store

New returns an empty, ready-to-use Store whose per-relabel OUT-side recount ceiling is maxRecountEdges (design §3.3.1). A maxRecountEdges of 0 or less disables the ceiling (the OUT side is always recounted exactly).

func (*Store) Apply

func (s *Store) Apply(d Delta)

Apply applies one buffered delta to its cell. A key is created on first observation and deleted when its counter returns to zero (bounded growth). A zero delta is a no-op.

It needs NO serialisation from the caller (rmp #2345). Every cell it touches goes through [addCell], whose aggregate is ORDER-INSENSITIVE — a cell is deleted at exactly zero and a negative cell is retained, so addition commutes — and each touch is made under that cell's own per-shard lock. Two writers applying concurrently therefore reach the same totals in any interleaving. The package contract states this at the top; it is restated here because this doc used to require the engine's write barrier, which has not serialised writers since rmp #2320.

func (*Store) Cells

func (s *Store) Cells() int

Cells reports the number of distinct live count cells currently held — the sum over every shard of the E, D(out), D(in) and T map sizes. Because a cell is deleted the moment its counter returns to zero ([addCell]), every map entry is a live combination, so this is an exact, allocation-free size indicator for observability: it is bounded by the number of currently-observed schema combinations (design §2.3), never by |V| or |E|.

"The moment" is now a two-step moment, and this is the one place it shows. The writer whose increment lands on zero drops the shared lock and re-takes the exclusive one to unlink the key, so a Cells call racing that writer can count a cell that is one instruction from removal. The over-count is bounded by the number of writers concurrently crossing zero, it is transient — the crossing writer always completes the unlink, and at quiescence no zero-valued cell survives — and it errs HIGH, so it can never hide a footprint the bound exists to catch. Every quiescent reading is exact, which is what the simulator's cells-bound invariant asserts against.

It takes each shard's EXCLUSIVE lock, for the reason given on Store.Snapshot, and is safe to call concurrently with writers. The metrics [Backend] exposes no gauge, so this is the accessor an observer reads to surface the store's footprint (task #2087).

func (*Store) CountD

func (s *Store) CountD(label, rt uint32, dir Direction) int64

CountD returns the degree-sum D(label, rt, dir) (0 when absent). It ignores the dirty flag; callers that need the exactness verdict consult Store.DDirty.

It takes no lock, for the reason given on Store.CountE.

func (*Store) CountE

func (s *Store) CountE(rt uint32) int64

CountE returns the live edge count of relationship type rt (0 when absent).

It takes NO LOCK: the published cell map is immutable, so loading it is a plain atomic load and reading it is an ordinary map lookup. See the package documentation for the measurement that removed the read lock (rmp #2682).

A cell a concurrent writer unlinks between the map load and the counter read still reads as the value it held, which is the value that made it eligible for unlinking: exactly zero. Either answer is a legal snapshot read, and this one cannot be wrong.

func (*Store) CountT

func (s *Store) CountT(a, rt, b uint32) int64

CountT returns the triple count T(a, rt, b) (0 when absent). It ignores the dirty flag; callers that need the exactness verdict consult Store.TDirty.

It takes no lock, for the reason given on Store.CountE.

func (*Store) DDirty

func (s *Store) DDirty(label uint32, dir Direction) bool

DDirty reports whether D(label, *, dir) is currently non-exact.

func (*Store) MarkDirty

func (s *Store) MarkDirty(m DirtyMark)

MarkDirty toggles off the exactness of one X-scoped family set. It is a mutation. It needs no caller serialisation, for the order-insensitivity reason given on Store.Apply.

func (*Store) MaxRecountEdges

func (s *Store) MaxRecountEdges() int

MaxRecountEdges reports the per-relabel OUT-side recount ceiling (0 or less means unbounded). The relabel maintenance consults it to decide between an exact OUT-side recount and an X-scoped OUT dirty marking (design §3.3.1).

func (*Store) RecomputeReset

func (s *Store) RecomputeReset()

RecomputeReset clears every cell and every dirty flag, returning the store to its empty state. It is the seam an O(V+E) recompute-from-graph (task #2084) resets before replaying the create-deltas of every live edge; clearing the dirty sets restores full exactness. It is a mutation, and needs no caller serialisation for the order-insensitivity reason given on Store.Apply.

It publishes a FRESH empty map per family rather than clearing the published one, because the published map is immutable: a concurrent lock-free reader may be walking it, and clearing it under that reader would be a data race as well as a torn answer.

func (*Store) Snapshot

func (s *Store) Snapshot() Snapshot

Snapshot returns a copy of every cell whose counter is currently NON-ZERO, and every dirty marking. It is safe to call concurrently with writers, which are NOT serialised against each other.

It takes each shard's EXCLUSIVE lock, which is what freezes that shard for the duration of its scan: increments hold the SHARED lock (see [addCell]), so a shared hold here would no longer exclude them and the per-shard scan would no longer be atomic. The exclusive hold restores exactly the property the read hold used to give when increments were exclusive. It is not on any request path — the engine calls it for observability and the simulator for parity checking — so the cost of blocking a shard's writers for the length of one shard's scan is paid by the observer, never by the workload.

NEGATIVE cells are included. This doc used to say "every live cell (value > 0)", which the code has never done: the predicate is `v != 0`, and it must be, because [addCell] deliberately RETAINS a cell driven negative rather than clamping it — that retention is what makes the aggregate order-insensitive (rmp #2303). A negative cell is reachable from ordinary Cypher, not only from concurrent writers: MEASURED, `SET a:X` then `SET b:X` then `REMOVE a:X` over an edge `a -> b` leaves T(X, rel, X) at -1, because the +1 was never applied (b had no out-edge, so the relabel's OUT recount returned early) while the -1 was, b having acquired X by then. Such a cell is always covered by the DirtyMark the same relabel raised, so it is non-exact rather than wrong; a consumer that treats an absent key as zero and assumes every present value is positive will nonetheless mis-read it.

func (*Store) TDirty

func (s *Store) TDirty(a, b uint32) bool

TDirty reports whether T(a, *, b) is currently non-exact — true when either the a-position label or the b-position label has been marked dirty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL