Skip to content

Add deephaven-seqlock, a writer-biased sequence lock - #1

Open
devinrsmith wants to merge 14 commits into
mainfrom
seqlock-review-1
Open

devinrsmith wants to merge 14 commits into
mainfrom
seqlock-review-1

Conversation

@devinrsmith

@devinrsmith devinrsmith commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

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.
@devinrsmith
devinrsmith requested a balanced review from Copilot September 22, 2026 23:48
@devinrsmith devinrsmith self-assigned this Sep 22, 2026
@devinrsmith
devinrsmith added this pull request to stack #3 September 22, 2026 23:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Maven publication and timed/interruptible read behavior have unresolved critical and moderate issues.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds a Java 8+ multi-release SeqLock library with optimistic readers, polling APIs, tests, documentation, and Maven publishing support.

Changes:

  • Implements SeqLock with Java 8 and Java 11+ shims.
  • Adds concurrency, interruption, stress, and multi-release tests.
  • Adds Gradle packaging, publishing, licensing, and documentation.

Review findings:

  • Critical (1 vote): seqlock/build.gradle.kts:110 lacks Maven Central-required project URL, SCM, and developer POM metadata.
  • Moderate (3 votes): SeqLock.java:250,294 uses non-wrap-safe absolute nanoTime() deadlines.
  • Moderate (1 vote): SeqLock.java:285 may return before propagating a pending interrupt.
File Description
settings.gradle.kts Configures the project and artifact name.
seqlock/​src/​test/​java11/​io/​deephaven/​seqlock/​JavaVersionShimTest.java Tests Java 11 override routing.
seqlock/​src/​test/​java/​io/​deephaven/​seqlock/​SeqLockTest.java Tests locking, polling, and interruption behavior.
seqlock/​src/​test/​java/​io/​deephaven/​seqlock/​RequestStatsExampleTest.java Stress-tests coherent snapshots.
seqlock/​src/​test/​java/​io/​deephaven/​seqlock/​RequestStatsExample.java Demonstrates related-field publication.
seqlock/​src/​test/​java/​io/​deephaven/​seqlock/​JavaVersionShimTest.java Tests base-version routing.
seqlock/​src/​main/​java11/​io/​deephaven/​seqlock/​VarHandleShim.java Provides Java 11 fences.
seqlock/​src/​main/​java11/​io/​deephaven/​seqlock/​ThreadShim.java Uses Java 11 spin-wait support.
seqlock/​src/​main/​java11/​io/​deephaven/​seqlock/​JavaVersionShim.java Identifies the Java 11 override.
seqlock/​src/​main/​java/​io/​deephaven/​seqlock/​VarHandleShim.java Provides Java 8 fences.
seqlock/​src/​main/​java/​io/​deephaven/​seqlock/​ThreadShim.java Provides Java 8 spin behavior.
seqlock/​src/​main/​java/​io/​deephaven/​seqlock/​SeqLock.java Implements the public seqlock API.
seqlock/​src/​main/​java/​io/​deephaven/​seqlock/​JavaVersionShim.java Identifies the base implementation.
seqlock/​build.gradle.kts Configures MR-JAR builds, tests, and publishing.
README.md Documents installation, usage, and memory semantics.
LICENSE Adds the Apache 2.0 license.
gradlew.bat Adds the Windows Gradle launcher.
gradlew Adds the POSIX Gradle launcher.
gradle/​wrapper/​gradle-wrapper.properties Pins the Gradle distribution.
gradle/​license-header.txt Defines source license headers.
gradle/​libs.versions.toml Centralizes dependency and plugin versions.
gradle.properties Enables Gradle configuration caching.
.gitignore Ignores generated and IDE files.
.gitattributes Configures line endings and binary handling.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread seqlock/build.gradle.kts
Comment thread seqlock/src/main/java/io/deephaven/seqlock/SeqLock.java Outdated
Copilot AI review requested due to automatic review settings September 23, 2026 00:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

A critical test flaw and unresolved moderate API and concurrency-contract issues must be addressed.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Honor interruption on beginReadInterruptible fast paths

seqlock/​src/​main/​java/​io/​deephaven/​seqlock/​SeqLock.java:286

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).

Comment thread seqlock/src/test/java/io/deephaven/seqlock/SeqLockTest.java
Copilot AI review requested due to automatic review settings September 23, 2026 00:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The single-writer invariant is not actually enforced across concurrent writer threads.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Enforce or document single-writer invariant

seqlock/​src/​main/​java/​io/​deephaven/​seqlock/​SeqLock.java:161

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.

Low severity Test interruption during Thread.sleep

seqlock/​src/​test/​java/​io/​deephaven/​seqlock/​SeqLockTest.java:205

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.
Copilot AI review requested due to automatic review settings September 23, 2026 15:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

A critical writer-safety flaw and unresolved timing and concurrency-test gaps require correction and human review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Test interruption during polling sleep

seqlock/​src/​test/​java/​io/​deephaven/​seqlock/​SeqLockTest.java:205

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.

Comment thread seqlock/src/main/java/io/deephaven/seqlock/SeqLock.java Outdated
- "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.
Copilot AI review requested due to automatic review settings September 23, 2026 15:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Unresolved moderate issues affect writer safety and test reliability and coverage.

Review effort: Balanced
Findings: 1 High severity

Open (1)

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).
Copilot AI review requested due to automatic review settings September 23, 2026 17:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Unresolved moderate issues affect the stated writer guarantees and test correctness or reliability.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Write section crosses threads, violating writer affinity

seqlock/​src/​test/​java/​io/​deephaven/​seqlock/​SeqLockTest.java:111

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:

  • line 122
  • line 148
Low severity Pre-interrupted reader bypasses interruption-during-wait coverage

seqlock/​src/​test/​java/​io/​deephaven/​seqlock/​SeqLockTest.java:213

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.
Copilot AI review requested due to automatic review settings September 23, 2026 17:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Unresolved concurrency-contract, interruption, test-reliability, and publishing issues require human review.

Review effort: Balanced
Findings: None

…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.
Copilot AI review requested due to automatic review settings September 23, 2026 18:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The concurrency contract and timing-sensitive tests have unresolved correctness and reliability concerns.

Review effort: Balanced
Findings: None

…, 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.
Copilot AI balanced review requested due to automatic review settings October 1, 2026 00:37
@devinrsmith devinrsmith changed the title Add deephaven-seqlock, a writer-biased optimistic concurrency primitive Add deephaven-seqlock, a writer-biased sequence lock Oct 1, 2026
@devinrsmith
devinrsmith requested a review from jcferretti October 1, 2026 00:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The implementation omits advertised APIs and contains a sequence-overflow correctness issue plus nondeterministic test synchronization.

Review effort: Balanced
Findings: 1 High severity · 3 Medium severity

Open (4)

Comment thread seqlock/src/main/java/io/deephaven/seqlock/SeqLock.java Outdated
Comment thread seqlock/src/main/java/io/deephaven/seqlock/SeqLock.java
Comment thread seqlock/src/main/java/io/deephaven/seqlock/ThreadShim.java
Comment on lines +116 to +120
Thread.sleep(testDurationMillis);
} finally {
stop.set(true);
writer.join();
readers.shutdown();
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.
Copilot AI balanced review requested due to automatic review settings October 1, 2026 01:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The low-level memory-ordering guarantees and multi-runtime packaging warrant final expert concurrency review.

Review effort: Balanced
Findings: 1 Medium severity · 1 Low severity

Open (2)
Resolved since last review (3)

Comment on lines +202 to +219
/**
* Starts a thread that begins a write section, holds it for ~30ms, sets {@code protectedValue[0]}
* to 42, and ends it -- beginWrite and endWrite both on that thread. Returns once the write
* section has begun, so on return a write is known to be in progress; join the returned thread to
* know it has ended.
*/
/**
* Forces the sequence to {@code value} (which must be odd: readable) through the private fields.
*/
private void setSequence(long value) throws ReflectiveOperationException {
for (String field : new String[] {"sequence", "writerSeq"}) {
final java.lang.reflect.Field f = SeqLock.class.getDeclaredField(field);
f.setAccessible(true);
f.setLong(lock, value);
}
}

private Thread startWriterHoldingWriteSection(long[] protectedValue) throws InterruptedException {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants