use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
#[cfg(feature = "commit-combiner-test-support")]
use std::sync::{Condvar, Mutex as StdMutex, MutexGuard};
#[cfg(feature = "commit-combiner-test-support")]
use std::time::{Duration, Instant as StdInstant};
use smallvec::SmallVec;
use fsqlite_types::CommitSeq;
use fsqlite_types::sync_primitives::{Instant, Mutex};
pub const MAX_COMMIT_THREADS: usize = 64;
const SLOT_EMPTY: u8 = 0;
const SLOT_PENDING: u8 = 1;
const SLOT_DONE: u8 = 2;
const SPIN_BEFORE_YIELD: u32 = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct CommitCombineMetrics {
pub batches_total: u64,
pub ops_total: u64,
pub batch_size_sum: u64,
pub batch_size_max: u64,
pub wait_ns_total: u64,
pub wait_ns_max: u64,
}
#[cfg(feature = "commit-combiner-test-support")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommitCombineTestReceipt {
pub next_seq: u64,
pub metrics: CommitCombineMetrics,
pub registered_allocations: u64,
pub one_shot_allocations: u64,
}
#[cfg(feature = "commit-combiner-test-support")]
struct CommitCombineTestMetricRecorder {
registered_allocations: AtomicU64,
one_shot_allocations: AtomicU64,
}
#[cfg(feature = "commit-combiner-test-support")]
impl CommitCombineTestMetricRecorder {
const fn new() -> Self {
Self {
registered_allocations: AtomicU64::new(0),
one_shot_allocations: AtomicU64::new(0),
}
}
}
#[cfg(feature = "commit-combiner-test-support")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommitCombineStagingReceipt {
pub expected_callers: usize,
pub staged_callers: usize,
}
#[cfg(feature = "commit-combiner-test-support")]
struct CommitCombineStagingState {
staged_callers: usize,
released: bool,
}
#[cfg(feature = "commit-combiner-test-support")]
pub struct CommitCombineStagingControl {
expected_callers: usize,
state: StdMutex<CommitCombineStagingState>,
all_staged: Condvar,
release_waiters: Condvar,
route_metrics: CommitCombineTestMetricRecorder,
}
#[cfg(feature = "commit-combiner-test-support")]
impl CommitCombineStagingControl {
#[must_use]
pub fn new(expected_callers: usize) -> Self {
assert!(
expected_callers > 0 && expected_callers <= MAX_COMMIT_THREADS,
"staging control requires 1..=MAX_COMMIT_THREADS callers"
);
Self {
expected_callers,
state: StdMutex::new(CommitCombineStagingState {
staged_callers: 0,
released: false,
}),
all_staged: Condvar::new(),
release_waiters: Condvar::new(),
route_metrics: CommitCombineTestMetricRecorder::new(),
}
}
#[must_use]
pub fn wait_until_all_staged(&self, timeout: Duration) -> bool {
let deadline = StdInstant::now() + timeout;
let mut state = self.lock_state();
while state.staged_callers < self.expected_callers {
let Some(remaining) = deadline.checked_duration_since(StdInstant::now()) else {
return false;
};
let (next_state, timeout_result) = self
.all_staged
.wait_timeout(state, remaining)
.unwrap_or_else(std::sync::PoisonError::into_inner);
state = next_state;
if timeout_result.timed_out() && state.staged_callers < self.expected_callers {
return false;
}
}
true
}
#[must_use]
pub fn release_when_all_staged(&self) -> CommitCombineStagingReceipt {
let mut state = self.lock_state();
assert_eq!(
state.staged_callers, self.expected_callers,
"cannot release a partial staged batch"
);
state.released = true;
self.release_waiters.notify_all();
CommitCombineStagingReceipt {
expected_callers: self.expected_callers,
staged_callers: state.staged_callers,
}
}
#[must_use]
pub fn release_guard(self: &Arc<Self>) -> CommitCombineStagingReleaseGuard {
CommitCombineStagingReleaseGuard {
control: Arc::clone(self),
}
}
fn lock_state(&self) -> MutexGuard<'_, CommitCombineStagingState> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn stage_registered_call(&self) {
let mut state = self.lock_state();
if state.released {
return;
}
assert!(
state.staged_callers < self.expected_callers,
"staging control received more callers than configured"
);
state.staged_callers += 1;
if state.staged_callers == self.expected_callers {
self.all_staged.notify_all();
}
while !state.released {
state = self
.release_waiters
.wait(state)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
}
fn release_for_drop(&self) {
let mut state = self.lock_state();
state.released = true;
self.release_waiters.notify_all();
}
fn record_registered_allocation(&self) {
self.route_metrics
.registered_allocations
.fetch_add(1, Ordering::Relaxed);
}
fn record_one_shot_allocation(&self) {
self.route_metrics
.one_shot_allocations
.fetch_add(1, Ordering::Relaxed);
}
fn route_metrics(&self) -> (u64, u64) {
(
self.route_metrics
.registered_allocations
.load(Ordering::Acquire),
self.route_metrics
.one_shot_allocations
.load(Ordering::Acquire),
)
}
}
#[cfg(feature = "commit-combiner-test-support")]
pub struct CommitCombineStagingReleaseGuard {
control: Arc<CommitCombineStagingControl>,
}
#[cfg(feature = "commit-combiner-test-support")]
impl Drop for CommitCombineStagingReleaseGuard {
fn drop(&mut self) {
self.control.release_for_drop();
}
}
struct CommitCombineMetricRecorder {
batches_total: AtomicU64,
ops_total: AtomicU64,
batch_size_sum: AtomicU64,
batch_size_max: AtomicU64,
wait_ns_total: AtomicU64,
wait_ns_max: AtomicU64,
}
impl CommitCombineMetricRecorder {
const fn new() -> Self {
Self {
batches_total: AtomicU64::new(0),
ops_total: AtomicU64::new(0),
batch_size_sum: AtomicU64::new(0),
batch_size_max: AtomicU64::new(0),
wait_ns_total: AtomicU64::new(0),
wait_ns_max: AtomicU64::new(0),
}
}
fn snapshot(&self) -> CommitCombineMetrics {
CommitCombineMetrics {
batches_total: self.batches_total.load(Ordering::Relaxed),
ops_total: self.ops_total.load(Ordering::Relaxed),
batch_size_sum: self.batch_size_sum.load(Ordering::Relaxed),
batch_size_max: self.batch_size_max.load(Ordering::Relaxed),
wait_ns_total: self.wait_ns_total.load(Ordering::Relaxed),
wait_ns_max: self.wait_ns_max.load(Ordering::Relaxed),
}
}
fn record_wait(&self, elapsed_ns: u64) {
self.wait_ns_total.fetch_add(elapsed_ns, Ordering::Relaxed);
update_max(&self.wait_ns_max, elapsed_ns);
}
fn record_batch(&self, pending_count: u64) {
self.batches_total.fetch_add(1, Ordering::Relaxed);
self.ops_total.fetch_add(pending_count, Ordering::Relaxed);
self.batch_size_sum
.fetch_add(pending_count, Ordering::Relaxed);
update_max(&self.batch_size_max, pending_count);
}
}
fn update_max(metric: &AtomicU64, val: u64) {
let mut prev = metric.load(Ordering::Relaxed);
while val > prev {
match metric.compare_exchange_weak(prev, val, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break,
Err(actual) => prev = actual,
}
}
}
#[repr(align(64))]
struct CommitSlot {
state: AtomicU8,
_pad1: [u8; 7],
result: AtomicU64,
_pad2: [u8; 48],
}
impl CommitSlot {
const fn new() -> Self {
Self {
state: AtomicU8::new(SLOT_EMPTY),
_pad1: [0; 7],
result: AtomicU64::new(0),
_pad2: [0; 48],
}
}
}
pub struct CommitSequenceCombiner {
next_commit_seq: AtomicU64,
slots: [CommitSlot; MAX_COMMIT_THREADS],
owners: [AtomicU64; MAX_COMMIT_THREADS],
combiner_lock: Mutex<()>,
active_registry: Option<Arc<Mutex<SmallVec<[u64; 16]>>>>,
metrics: CommitCombineMetricRecorder,
#[cfg(feature = "commit-combiner-test-support")]
staging_control: Option<Arc<CommitCombineStagingControl>>,
}
impl CommitSequenceCombiner {
pub fn new(initial_commit_seq: u64) -> Self {
Self {
next_commit_seq: AtomicU64::new(initial_commit_seq),
slots: std::array::from_fn(|_| CommitSlot::new()),
owners: std::array::from_fn(|_| AtomicU64::new(0)),
combiner_lock: Mutex::new(()),
active_registry: None,
metrics: CommitCombineMetricRecorder::new(),
#[cfg(feature = "commit-combiner-test-support")]
staging_control: None,
}
}
pub fn new_with_registry(
initial_commit_seq: u64,
registry: Arc<Mutex<SmallVec<[u64; 16]>>>,
) -> Self {
Self {
next_commit_seq: AtomicU64::new(initial_commit_seq),
slots: std::array::from_fn(|_| CommitSlot::new()),
owners: std::array::from_fn(|_| AtomicU64::new(0)),
combiner_lock: Mutex::new(()),
active_registry: Some(registry),
metrics: CommitCombineMetricRecorder::new(),
#[cfg(feature = "commit-combiner-test-support")]
staging_control: None,
}
}
#[cfg(feature = "commit-combiner-test-support")]
#[must_use]
pub fn new_with_staging_control(
initial_commit_seq: u64,
staging_control: Arc<CommitCombineStagingControl>,
) -> Self {
let mut combiner = Self::new(initial_commit_seq);
combiner.staging_control = Some(staging_control);
combiner
}
pub fn register(&self) -> Option<CommitCombineHandle<'_>> {
let tid = thread_id_hash();
for i in 0..MAX_COMMIT_THREADS {
if self.owners[i]
.compare_exchange(0, tid, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
return Some(CommitCombineHandle {
combiner: self,
slot: i,
});
}
}
None
}
#[must_use]
pub fn next_seq(&self) -> u64 {
self.next_commit_seq.load(Ordering::Acquire)
}
#[must_use]
pub fn active_threads(&self) -> usize {
self.owners
.iter()
.filter(|o| o.load(Ordering::Relaxed) != 0)
.count()
}
#[must_use]
pub fn metrics(&self) -> CommitCombineMetrics {
self.metrics.snapshot()
}
#[cfg(feature = "commit-combiner-test-support")]
#[must_use]
pub fn test_support_receipt(&self) -> CommitCombineTestReceipt {
let (registered_allocations, one_shot_allocations) = self
.staging_control
.as_ref()
.map_or((0, 0), |staging_control| staging_control.route_metrics());
CommitCombineTestReceipt {
next_seq: self.next_seq(),
metrics: self.metrics(),
registered_allocations,
one_shot_allocations,
}
}
fn claim_slot(&self, tid: u64) -> usize {
loop {
for i in 0..MAX_COMMIT_THREADS {
if self.owners[i]
.compare_exchange(0, tid, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
return i;
}
}
std::thread::yield_now();
}
}
pub fn alloc_one_shot(&self) -> CommitSeq {
let start = Instant::now();
let tid = thread_id_hash();
let slot = self.claim_slot(tid);
self.slots[slot]
.state
.store(SLOT_PENDING, Ordering::Release);
if let Some(_guard) = self.combiner_lock.try_lock() {
self.combine_locked();
}
let mut spins = 0u32;
let seq = loop {
let state = self.slots[slot].state.load(Ordering::Acquire);
if state == SLOT_DONE {
let raw = self.slots[slot].result.load(Ordering::Acquire);
self.slots[slot].state.store(SLOT_EMPTY, Ordering::Release);
break CommitSeq::new(raw);
}
spins += 1;
if spins < SPIN_BEFORE_YIELD {
std::hint::spin_loop();
} else {
if let Some(_guard) = self.combiner_lock.try_lock() {
self.combine_locked();
} else {
std::thread::yield_now();
}
spins = 0;
}
};
self.owners[slot].store(0, Ordering::Release);
#[allow(clippy::cast_possible_truncation)]
let elapsed_ns = start.elapsed().as_nanos() as u64;
self.metrics.record_wait(elapsed_ns);
#[cfg(feature = "commit-combiner-test-support")]
if let Some(staging_control) = &self.staging_control {
staging_control.record_one_shot_allocation();
}
seq
}
fn combine_locked(&self) {
let mut pending_count = 0u64;
let mut pending_slots = [false; MAX_COMMIT_THREADS];
for (slot, is_pending) in self.slots.iter().zip(pending_slots.iter_mut()) {
let state = slot.state.load(Ordering::Acquire);
if state == SLOT_PENDING {
*is_pending = true;
pending_count += 1;
}
}
if pending_count == 0 {
return;
}
let base_seq = if let Some(ref registry) = self.active_registry {
let mut active = registry.lock();
let base = self
.next_commit_seq
.fetch_add(pending_count, Ordering::AcqRel);
for i in 0..pending_count {
active.push(base + i);
}
base
} else {
self.next_commit_seq
.fetch_add(pending_count, Ordering::AcqRel)
};
debug_assert!(
base_seq < u64::MAX - pending_count,
"CommitSeq allocation overflow"
);
let mut assigned = 0u64;
for (slot, is_pending) in self.slots.iter().zip(pending_slots.iter()) {
if *is_pending {
let seq = base_seq + assigned;
assigned += 1;
slot.result.store(seq, Ordering::Release);
slot.state.store(SLOT_DONE, Ordering::Release);
}
}
debug_assert_eq!(assigned, pending_count);
self.metrics.record_batch(pending_count);
tracing::debug!(
target: "fsqlite.commit_combine",
batch_size = pending_count,
base_seq,
"commit_combine_batch"
);
}
}
#[allow(clippy::missing_fields_in_debug)]
impl std::fmt::Debug for CommitSequenceCombiner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CommitSequenceCombiner")
.field("next_seq", &self.next_seq())
.field("active_threads", &self.active_threads())
.finish_non_exhaustive()
}
}
pub struct CommitCombineHandle<'a> {
combiner: &'a CommitSequenceCombiner,
slot: usize,
}
impl CommitCombineHandle<'_> {
pub fn alloc_commit_seq(&self) -> CommitSeq {
let start = Instant::now();
self.combiner.slots[self.slot]
.state
.store(SLOT_PENDING, Ordering::Release);
#[cfg(feature = "commit-combiner-test-support")]
let staging_control = self.combiner.staging_control.as_ref();
#[cfg(feature = "commit-combiner-test-support")]
if let Some(staging_control) = staging_control {
staging_control.stage_registered_call();
}
if let Some(_guard) = self.combiner.combiner_lock.try_lock() {
self.combiner.combine_locked();
}
let mut spins = 0u32;
loop {
let state = self.combiner.slots[self.slot].state.load(Ordering::Acquire);
if state == SLOT_DONE {
let seq = self.combiner.slots[self.slot]
.result
.load(Ordering::Acquire);
self.combiner.slots[self.slot]
.state
.store(SLOT_EMPTY, Ordering::Release);
#[allow(clippy::cast_possible_truncation)]
let elapsed_ns = start.elapsed().as_nanos() as u64;
self.combiner.metrics.record_wait(elapsed_ns);
#[cfg(feature = "commit-combiner-test-support")]
if let Some(staging_control) = staging_control {
staging_control.record_registered_allocation();
}
return CommitSeq::new(seq);
}
spins += 1;
if spins < SPIN_BEFORE_YIELD {
std::hint::spin_loop();
} else {
if let Some(_guard) = self.combiner.combiner_lock.try_lock() {
self.combiner.combine_locked();
} else {
std::thread::yield_now();
}
spins = 0;
}
}
}
#[must_use]
pub fn slot(&self) -> usize {
self.slot
}
}
impl Drop for CommitCombineHandle<'_> {
fn drop(&mut self) {
self.combiner.slots[self.slot]
.state
.store(SLOT_EMPTY, Ordering::Release);
self.combiner.owners[self.slot].store(0, Ordering::Release);
}
}
fn thread_id_hash() -> u64 {
let t = std::thread::current().id();
let s = format!("{t:?}");
let mut h = 1u64;
for b in s.bytes() {
h = h.wrapping_mul(31).wrapping_add(u64::from(b));
}
if h == 0 { 1 } else { h }
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Barrier};
use std::thread;
#[test]
fn test_combiner_single_thread() {
let combiner = CommitSequenceCombiner::new(100);
let handle = combiner.register().unwrap();
let seq1 = handle.alloc_commit_seq();
assert_eq!(seq1.get(), 100);
let seq2 = handle.alloc_commit_seq();
assert_eq!(seq2.get(), 101);
let seq3 = handle.alloc_commit_seq();
assert_eq!(seq3.get(), 102);
drop(handle);
assert_eq!(combiner.next_seq(), 103);
}
#[test]
fn test_combiner_private_staged_batch_records_one_batch() {
let combiner = CommitSequenceCombiner::new(100);
for slot in combiner.slots.iter().take(8) {
slot.state.store(SLOT_PENDING, Ordering::Release);
}
let guard = combiner.combiner_lock.lock();
combiner.combine_locked();
drop(guard);
let metrics = combiner.metrics();
assert_eq!(metrics.ops_total, 8);
assert_eq!(metrics.batches_total, 1);
assert_eq!(metrics.batch_size_sum, 8);
assert_eq!(metrics.batch_size_max, 8);
assert_eq!(combiner.next_seq(), 108);
assert!(metrics.batches_total < metrics.ops_total);
}
#[test]
fn test_combiner_metrics_are_instance_local() {
let active = CommitSequenceCombiner::new(0);
let untouched = CommitSequenceCombiner::new(0);
active.alloc_one_shot();
assert_eq!(active.metrics().ops_total, 1);
assert_eq!(
untouched.metrics(),
CommitCombineMetrics {
batches_total: 0,
ops_total: 0,
batch_size_sum: 0,
batch_size_max: 0,
wait_ns_total: 0,
wait_ns_max: 0,
}
);
}
#[cfg(feature = "commit-combiner-test-support")]
#[test]
fn test_support_receipt_route_counts_require_staging_control() {
let combiner = CommitSequenceCombiner::new(100);
assert_eq!(combiner.alloc_one_shot().get(), 100);
let handle = combiner.register().expect("first slot must be available");
assert_eq!(handle.alloc_commit_seq().get(), 101);
drop(handle);
let receipt = combiner.test_support_receipt();
assert_eq!(receipt.next_seq, 102);
assert_eq!(receipt.registered_allocations, 0);
assert_eq!(receipt.one_shot_allocations, 0);
}
#[test]
fn test_combiner_8t_all_commits_succeed() {
let combiner = Arc::new(CommitSequenceCombiner::new(1000));
let barrier = Arc::new(Barrier::new(8));
let mut handles = Vec::new();
for _ in 0..8 {
let c = Arc::clone(&combiner);
let b = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
let h = c.register().unwrap();
b.wait();
let mut seqs = Vec::new();
for _ in 0..100 {
seqs.push(h.alloc_commit_seq().get());
}
drop(h);
seqs
}));
}
let mut all_seqs = Vec::new();
for h in handles {
all_seqs.extend(h.join().unwrap());
}
all_seqs.sort();
let unique_count = all_seqs.len();
all_seqs.dedup();
assert_eq!(
all_seqs.len(),
unique_count,
"all commit sequences must be unique"
);
assert_eq!(all_seqs.len(), 800);
assert!(all_seqs.iter().all(|&s| s >= 1000 && s < 1800));
assert_eq!(combiner.next_seq(), 1800);
}
#[test]
fn test_combiner_16t_throughput() {
let combiner = Arc::new(CommitSequenceCombiner::new(0));
let barrier = Arc::new(Barrier::new(16));
let mut handles = Vec::new();
let start = Instant::now();
for _ in 0..16 {
let c = Arc::clone(&combiner);
let b = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
let h = c.register().unwrap();
b.wait();
for _ in 0..1000 {
h.alloc_commit_seq();
}
drop(h);
}));
}
for h in handles {
h.join().unwrap();
}
let elapsed = start.elapsed();
assert_eq!(combiner.next_seq(), 16000);
assert!(
elapsed.as_millis() < 1000,
"16000 commits took {}ms, expected < 1000ms",
elapsed.as_millis()
);
}
#[test]
fn test_combiner_cache_line_padding() {
assert_eq!(
std::mem::align_of::<CommitSlot>(),
64,
"CommitSlot must be 64-byte aligned"
);
assert_eq!(
std::mem::size_of::<CommitSlot>(),
64,
"CommitSlot must be exactly 64 bytes"
);
}
#[test]
fn test_combiner_batch_size_varies() {
let combiner = Arc::new(CommitSequenceCombiner::new(0));
{
let h = combiner.register().unwrap();
h.alloc_commit_seq();
drop(h);
}
assert_eq!(combiner.next_seq(), 1);
{
let barrier = Arc::new(Barrier::new(4));
let mut handles = Vec::new();
for _ in 0..4 {
let c = Arc::clone(&combiner);
let b = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
let h = c.register().unwrap();
b.wait();
h.alloc_commit_seq();
drop(h);
}));
}
for h in handles {
h.join().unwrap();
}
}
assert_eq!(combiner.next_seq(), 5);
}
#[test]
fn test_combiner_fairness() {
let combiner = Arc::new(CommitSequenceCombiner::new(0));
let barrier = Arc::new(Barrier::new(8));
let mut handles = Vec::new();
for tid in 0..8u64 {
let c = Arc::clone(&combiner);
let b = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
let h = c.register().unwrap();
b.wait();
let start = Instant::now();
let mut max_wait_ns = 0u64;
for _ in 0..50 {
let op_start = Instant::now();
h.alloc_commit_seq();
#[allow(clippy::cast_possible_truncation)]
let wait = op_start.elapsed().as_nanos() as u64;
max_wait_ns = max_wait_ns.max(wait);
}
let total = start.elapsed();
drop(h);
(tid, max_wait_ns, total)
}));
}
for h in handles {
let (tid, max_wait_ns, total) = h.join().unwrap();
assert!(
max_wait_ns < 10_000_000,
"thread {tid} max wait {max_wait_ns}ns > 10ms"
);
assert!(
total.as_millis() < 500,
"thread {tid} total time {}ms > 500ms",
total.as_millis()
);
}
}
}