You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Add deephaven-seqlock, a writer-biased sequence lock - #1
A single writer publishes shared state to any number of readers. Writes are
wait-free: beginWrite()/endWrite() complete in a fixed number of steps and
never wait on readers. Reads are optimistic: a reader copies the state between
beginRead() (or tryBeginRead(), which returns 0 while a write is in progress)
and validate(), and retries if a write overlapped the copy. Writes do not
notify readers; readers learn of a change only by reading.
Java 8+, shipped as a multi-release jar: VarHandle fences and
Thread.onSpinWait() on Java 11+, Unsafe fences and a plain spin on Java 8.
Apache-2.0.
io.deephaven.seqlock:deephaven-seqlock -- a SeqLock for Java: a single
writer thread mutates shared state without ever blocking, and any
number of reader threads read it without acquiring a lock, retrying
only if a write happened to overlap their read.
SeqLock
- beginWrite()/endWrite(): unconditional, bounded, no CAS retry loop,
independent of reader activity; asserts enforce single-writer,
non-reentrant use.
- beginRead()/validate(): spin until no write is in progress, then
validate a stamp after copying; beginReadInterruptible() propagates
interruption. tryBeginRead() is the try-once form;
tryBeginRead(pollInterval, totalWait, unit) polls with Thread.sleep
instead of spinning (releases the carrier on virtual threads), with
an ...Interruptible variant, a 1 ms poll floor, and interrupt-status
restoration in the non-interruptible form.
- Memory model contract in the class javadoc: shared fields need not be
volatile; code between beginRead and validate should only copy values
out (JLS §17.3 cited for the plain-field spin case). Fence placements
are tied to the guarantees by the (1)/(2)/(A)/(B)/(C) comments.
Multi-release JAR (me.champeau.mrjar, targetVersions 8 and 11)
- Base source set targets Java 8: fences via sun.misc.Unsafe, spin via
Thread.yield(). The java11 override (META-INF/versions/11) uses
VarHandle fences and Thread.onSpinWait(). JavaVersionShim exists only
to make the override mechanism testable (base returns 8, override 11).
- Manifest: Multi-Release, Implementation-Title/-Version,
Automatic-Module-Name = io.deephaven.seqlock.
Tests (JUnit 5, AssertJ)
- SeqLockTest covers the writer/reader protocol, the poll variants'
timing and interruption behavior, and the single-writer asserts.
- JavaVersionShimTest in both src/test/java and src/test/java11 proves
the base vs. override routing.
- RequestStatsExample(+Test) is the README's worked example of
protecting a group of related fields -- accumulate locally, publish
as a batch -- with a concurrent stress test that fails on any torn
snapshot and checks every reader observed published data.
- test runs on JDK 8; java11Test on 11; java17Test/java21Test/java25Test
rerun the java11 override's tests on those runtimes. check/build run
them all.
Build
- Gradle 9.7.1 Kotlin DSL, version catalog for plugins and libraries,
configuration cache on, Spotless (googleJavaFormat, ktlint, Apache-2.0
license header), vanniktech maven-publish targeting Maven Central
with signing and an Apache-2.0 <license> in the POM.
- The subproject directory stays seqlock/ while the published
artifactId is deephaven-seqlock (project(":seqlock").name in
settings.gradle.kts).
License: Apache-2.0 (LICENSE; bundled as META-INF/LICENSE in every jar;
header on every Java source file). README documents the guarantees,
usage patterns, the memory model contract, and the related-fields
pattern.
This fast path returns a successful stamp without checking the current interrupt status. Unlike beginReadInterruptible(), a caller that is already interrupted gets no InterruptedException when no write is in progress, despite this method being documented as the interruptible counterpart. Check and clear interruption before returning from the fast path (and before the non-positive-wait fast path).
These assertions do not enforce the single-writer invariant across threads: writerSeq is a shared plain field, so two concurrent beginWrite() calls can both observe the same even value and pass before racing the increments. One endWrite() can then publish an even sequence while the other writer is still mutating state (and the other can leave the sequence odd), invalidating readers. Please either add real ownership/serialization enforcement or document single-writer as an unchecked precondition rather than claiming the assertions enforce it.
Despite the test name, the interrupt flag is set before the call, so execution stops at the entry check and never tests interruption of Thread.sleep. Run the call on a helper thread, wait until that thread is sleeping, then interrupt and join it to cover the during-wait path.
- Memory model contract: copying a reference is fine when the object
behind it is immutable (String, Instant, an unmodifiable collection
the writer never touches again) and the writer swaps in a new object
rather than mutating the old one; the restriction now targets only
objects mutated in place, where a field read through the reference
after validate() lands outside the window.
- Class javadoc: drop the one-shot beginRead()/validate() example (the
retry loop is the expected pattern) and the polling read examples
(an advanced pattern the method javadocs cover). The README's
polling section goes too, replaced by a pointer in its API
reference. Remaining examples call lock.isReadStamp(stamp) rather
than a bare isReadStamp(stamp).
- beginRead(): state that validation should almost always be a loop
retrying until it succeeds, repeat the loop example there, and point
callers who would rather give up at tryBeginRead(). tryBeginRead()
gets the try-once example on itself likewise.
- Reorder SeqLock's read methods from basic to advanced: tryBeginRead(),
beginRead(), beginReadInterruptible(), validate(), then the polling
tryBeginRead(pollInterval, totalWait, unit) variants. Pure move.
Despite its name, this test interrupts the thread before calling the method, so the initial Thread.interrupted() check handles it exactly like the preceding test. It never exercises interruption while blocked in Thread.sleep, leaving the distinct “during wait” behavior unverified; run the call on another thread, wait until it is polling, then interrupt that thread and assert the propagated exception.
- "Protecting a group of related fields" now motivates the pattern
from the reader's need (several fields that must be seen as a
consistent set) instead of describing SeqLock's single sequence
counter, and shows two shapes of it in one java block sharing a
top-level Stats holder: RequestStats, whose recordSuccess() is its
own beginWrite()/endWrite() section with no publish(), and
RequestStatsBatched, which accumulates into writer-local fields and
copies them to the shared fields in one write section per publish().
- Trade-offs between the two: the direct form puts a small,
unconditional write section on the hot path and gives readers the
latest value with nothing to schedule, but every write is a chance to
make a reader retry; the batched form makes the hot path two plain
increments and readers rarely collide, at the cost of data that lags
by a publish interval and a publish() to schedule. Start direct;
batch when the hot path is hot enough to matter or freshness can lag.
- The per-field and per-logical-group setter paragraphs are dropped.
- Method comments in the examples are one-line javadoc ("Writer thread
only." / "Any reader thread, any time") rather than trailing,
column-padded comments.
- RequestStatsExample/Test remain the runnable version of the batched
variant and are referenced as such.
Also drops the comment in settings.gradle.kts.
SeqLock takes it as given that there is exactly one writer -- a single
thread issuing one beginWrite()/endWrite() section at a time -- and
using it with more than one writer results in undefined behavior. The
class javadoc says so up front, beginWrite() repeats it, and the README
carries the same paragraph in its writer section.
Also drops the remaining pointers to StampedLock (class javadoc and the
README's "Why" section).
This opens the write section on the JUnit thread but closes it on writer. SeqLock explicitly requires one writer thread to issue each begin/end section (SeqLock.java:34-36), so the test exercises undefined usage and can mask regressions involving writer affinity. Have the worker perform both calls and use a latch to signal when the write is active.
This issue also appears in the following locations of the same file:
This test sets the interrupt flag before entering the method, so the entry check at SeqLock.java:349 throws immediately and the polling sleep is never reached. It therefore duplicates the preceding test rather than covering interruption during a wait; interrupt a reader only after it reaches TIMED_WAITING.
…rrupt
- tryBeginReadPollSucceedsAfterWriteCompletes,
tryBeginReadPollIntervalRoundedUpToOneMilli, and
tryBeginReadPollIgnoresInterruption called beginWrite() on the test
thread and endWrite() on a helper thread. A shared helper now starts
a writer thread that does beginWrite(), signals a CountDownLatch,
holds the section ~30ms, sets the protected value, and endWrite()s in
a finally; it returns once the latch fires, so the test thread knows a
write is in progress before it polls. The other tests were audited:
beginReadDuringWrite and RequestStatsExampleTest already pair the
calls on one thread; tryBeginReadDuringWrite began a write it never
ended and now ends it in a finally.
- tryBeginReadInterruptiblePollThrowsIfInterruptedDuringWait set the
interrupt flag before the call, so it tripped the method's up-front
interrupted() check and never reached the sleep between polls. A
helper thread now interrupts the test thread ~30ms in, while the
method is asleep between 5ms polls with a 2s budget, so the exception
has to come out of the sleep. The write section stays on the test
thread.
- Tests that catch InterruptedException no longer clear the interrupt
flag afterwards: Thread.sleep clears it before throwing, and the
up-front check is Thread.interrupted(), which clears it too.
tryBeginReadPollIgnoresInterruption keeps its clear, since it catches
nothing and the method under test deliberately leaves the flag set.
…rrupt
- tryBeginReadPollSucceedsAfterWriteCompletes,
tryBeginReadPollIntervalRoundedUpToOneMilli, and
tryBeginReadPollIgnoresInterruption called beginWrite() on the test
thread and endWrite() on a helper thread. A shared helper now starts
a writer thread that does beginWrite(), signals a CountDownLatch,
holds the section ~30ms, sets the protected value, and endWrite()s in
a finally; it returns once the latch fires, so the test thread knows a
write is in progress before it polls. The other tests were audited:
beginReadDuringWrite and RequestStatsExampleTest already pair the
calls on one thread; tryBeginReadDuringWrite began a write it never
ended and now ends it in a finally.
- tryBeginReadInterruptiblePollThrowsIfInterruptedDuringWait set the
interrupt flag before the call, so it tripped the method's up-front
interrupted() check and never reached the sleep between polls. A
helper thread now interrupts the test thread ~30ms in, while the
method is asleep between 5ms polls with a 2s budget, so the exception
has to come out of the sleep. The write section stays on the test
thread.
- Tests that catch InterruptedException no longer clear the interrupt
flag afterwards: Thread.sleep clears it before throwing, and the
up-front check is Thread.interrupted(), which clears it too.
tryBeginReadPollIgnoresInterruption keeps its clear, since it catches
nothing and the method under test deliberately leaves the flag set.
No test asserted validate() returning false -- the case the primitive
exists for. Three single-threaded tests now do: a stamp fails after an
intervening write (while a fresh stamp validates), fails while a write
is in progress (and stays failed once it completes), and never validates
again across many later writes.
Interruption was only tested with the flag pre-set, which trips the
up-front interrupted() check rather than the spin. Added:
beginReadInterruptibleThrowsIfInterruptedWhileSpinning interrupts from
another thread ~30ms into a spin on a held write, and
beginReadPreservesInterruptFlag checks that beginRead() spins straight
through a pending interrupt and leaves the flag set, as documented.
The mid-poll interrupt test and the new spin test share a small
interruptCurrentThreadAfter(delayMillis) helper.
…, docs
The library-side changes from the seqlock-jmh branch, without the
benchmark scaffolding:
- tryBeginRead() returns 0 while a write is in progress (the StampedLock
convention); isReadStamp is gone and callers test `stamp != 0`.
validate(0) is always false. The parity test is inlined at each site
and written as a branch in tryBeginRead, which measured within noise
of the previous code where a ternary did not.
- ORIGIN = 1: odd means readable, even means write in progress, so 0 is
a write-in-progress value by encoding, across wraparound included.
- The interruptible and polling read variants, newInstanceUnpadded and
the related tests and helper are removed from the initial scope, to
return in a later PR. The surface is newInstance, beginWrite, endWrite,
tryBeginRead, beginRead, validate.
- The Java 8 onSpinWait shim is a no-op rather than Thread.yield().
- Javadoc: a tightened introduction, the no-notification paragraph, the
happens-before statement in the memory-model contract, the
cache-coherence cost readers impose, usage examples on beginWrite and
both tryBeginRead patterns, "returns immediately" wording; -- rather
than em dashes in source. A test takes a stamp during a write and
checks validate rejects it.
- README: opens with the javadoc's definition verbatim, the "Why" and
hot-path-writer sections as trimmed, the mirrored single-writer and
read-window paragraphs updated to match the javadoc, examples using
`stamp != 0`; the benchmarks section is left out here.
devinrsmith
changed the title
Add deephaven-seqlock, a writer-biased optimistic concurrency primitive
Add deephaven-seqlock, a writer-biased sequence lock
Oct 1, 2026
Review finding: after 2^63 sections writerSeq + 1 wraps from -1 to 0,
and during that section tryBeginRead() returns the sentinel 0 while
validate(0) compares equal to the sequence -- a caller that skipped the
check could accept torn state, against the documented guarantee.
beginWrite now stores (writerSeq + 1) | MARK with MARK = bit 63, and
ORIGIN is MARK | 1. Every sequence value has the bit set, so none is
ever 0, and validate(0) is false by the same comparison that rejects
any other stale stamp -- no reader-side check, no writer-side branch,
one OR per section. endWrite needs nothing: adding 1 to an even value
cannot carry out of bit 0. The counter is the low 63 bits.
A test forces the wrap by setting the fields to -1 and checks that
tryBeginRead() is 0 and validate(0) is false during that section, and
that reads validate normally after it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A single writer publishes shared state to any number of readers. Writes are
wait-free: beginWrite()/endWrite() complete in a fixed number of steps and
never wait on readers. Reads are optimistic: a reader copies the state between
beginRead() (or tryBeginRead(), which returns 0 while a write is in progress)
and validate(), and retries if a write overlapped the copy. Writes do not
notify readers; readers learn of a change only by reading.
Java 8+, shipped as a multi-release jar: VarHandle fences and
Thread.onSpinWait() on Java 11+, Unsafe fences and a plain spin on Java 8.
Apache-2.0.