Skip to content
Open
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
4 changes: 2 additions & 2 deletions .github/workflows/bump-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
# PAT so the bump commit can be pushed to protected master.
# GITHUB_TOKEN (github-actions[bot]) cannot bypass branch rules.
Expand Down Expand Up @@ -120,7 +120,7 @@ jobs:
if: ${{ !inputs.beta || inputs.force_release }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ needs.bump.outputs.tag }}

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,19 @@ jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- run: cargo fmt --all --check

clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-targets --all-features -- -D warnings

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: Swatinem/rust-cache@v2
- run: cargo test --all-features
23 changes: 6 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,21 @@ concurrency:
cancel-in-progress: true

jobs:
# fmt, clippy and doc run on the dev toolchain, auto-installed by rustup from
# fmt and clippy run on the dev toolchain, auto-installed by rustup from
# rust-toolchain.toml. Only the test matrix pins explicit versions.
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- run: cargo fmt --all --check

clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-targets --all-features -- -D warnings

doc:
runs-on: ubuntu-latest
# The crate denies broken intra-doc links; this is the only place rustdoc
# runs, so enforce that (and every other rustdoc warning) here.
env:
RUSTDOCFLAGS: -D warnings
steps:
- uses: actions/checkout@v6
- uses: Swatinem/rust-cache@v2
- run: cargo doc --no-deps --all-features

test:
needs: [fmt, clippy]
strategy:
Expand Down Expand Up @@ -73,7 +62,7 @@ jobs:
run: |
pacman -Syu --noconfirm git curl base-devel

- uses: actions/checkout@v6
- uses: actions/checkout@v7

# Explicit per-version install: RUSTUP_TOOLCHAIN overrides
# rust-toolchain.toml, and the distro containers have no preinstalled Rust.
Expand All @@ -96,13 +85,13 @@ jobs:
ci-success:
name: CI success
if: ${{ always() }}
needs: [fmt, clippy, doc, test]
needs: [fmt, clippy, test]
runs-on: ubuntu-latest
steps:
- name: Check job results
run: |
for r in "${{ needs.fmt.result }}" "${{ needs.clippy.result }}" \
"${{ needs.doc.result }}" "${{ needs.test.result }}"; do
"${{ needs.test.result }}"; do
case "$r" in
success | skipped) ;;
*) echo "required job result: $r"; exit 1 ;;
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-crates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: Swatinem/rust-cache@v2

- name: Publish
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ All notable changes to this project are documented in this file.
The format follows Keep a Changelog, and the project uses Semantic Versioning.

## [Unreleased]

### Added

- Core public types: the `BatchCollector` trait, `BatchLoaderConfig`, and
`Error`. The runtime is not included yet - `BatchLoader` arrives in a later
release.
21 changes: 21 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ keywords = ["async", "batch", "dataloader", "dedup", "tokio"]
categories = ["asynchronous", "concurrency"]

[dependencies]
tokio = { version = "1", default-features = false, features = ["sync", "time"] }
tokio = { version = "1", default-features = false, features = ["sync", "time", "macros", "rt"] }
thiserror = "2"
tracing = { version = "0.1", optional = true }
metrics = { version = "0.24", optional = true }

Expand All @@ -21,4 +22,4 @@ tracing = ["dep:tracing"]
metrics = ["dep:metrics"]

[dev-dependencies]
tokio = { version = "1", default-features = false, features = ["macros", "rt"] }
tokio = { version = "1", default-features = false, features = ["macros", "rt", "test-util"] }
74 changes: 74 additions & 0 deletions examples/dedup_window.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use carpool::{BatchCollector, BatchLoader, BatchLoaderConfig};

// Records how many times it is actually called, so dedup shows up as an
// observable fact, not just as equal results. Output depends on the key alone,
// so it does not matter which duplicate became the batch representative.
#[derive(Clone)]
struct CountingSquares {
calls: Arc<AtomicUsize>,
}

impl BatchCollector for CountingSquares {
type Input = u64;
type Output = u64;
type Key = u64;
type Error = Infallible;

// Fold many inputs onto four keys so duplicates dominate the window.
fn key(&self, input: &u64) -> u64 {
*input % 4
}

async fn load(&self, batch: HashMap<u64, u64>) -> Result<HashMap<u64, u64>, Infallible> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(batch.into_keys().map(|k| (k, k * k)).collect())
}
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
let calls = Arc::new(AtomicUsize::new(0));
let loader = BatchLoader::spawn(
CountingSquares {
calls: calls.clone(),
},
BatchLoaderConfig::default(),
);

// A hundred concurrent loads, but only four distinct keys.
let handles: Vec<_> = (0..100u64)
.map(|i| {
let loader = loader.clone();
tokio::spawn(async move { loader.load(i).await })
})
.collect();

let mut results = Vec::new();
for handle in handles {
results.push(handle.await.expect("task joins").expect("load succeeds"));
}

let downstream_calls = calls.load(Ordering::SeqCst);
println!("loads: {}", results.len());
println!("distinct keys: 4");
println!("downstream calls: {downstream_calls}");

// The invariant, not the line order: one call served the whole window, and
// every load got the square of its key.
assert_eq!(results.len(), 100);
assert_eq!(
downstream_calls, 1,
"100 loads over 4 keys collapse to one downstream call"
);
for (i, result) in results.iter().enumerate() {
let key = i as u64 % 4;
assert_eq!(*result, key * key, "each load gets the square of its key");
}

println!("dedup confirmed: 100 loads served by {downstream_calls} downstream call");
}
44 changes: 44 additions & 0 deletions examples/quickstart.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Smoke demo: implement BatchCollector, read the default config,
// see that equal inputs map to the same key.
// Run: cargo run --example quickstart
// Expected output:
// config: window=30ms max_batch=1024 timeout=30s concurrency=None max_waiting=None
// key(7) = 7, key(7) = 7 -> equal inputs share one batch entry

use std::collections::HashMap;

use carpool::{BatchCollector, BatchLoaderConfig};

#[derive(Clone)]
struct SquareLoader;

impl BatchCollector for SquareLoader {
type Input = u64;
type Output = u64;
type Key = u64;
type Error = std::convert::Infallible;

fn key(&self, input: &u64) -> u64 {
*input
}

async fn load(&self, batch: HashMap<u64, u64>) -> Result<HashMap<u64, u64>, Self::Error> {
Ok(batch.into_iter().map(|(k, n)| (k, n * n)).collect())
}
}

fn main() {
let cfg = BatchLoaderConfig::default();
println!(
"config: window={:?} max_batch={} timeout={:?} concurrency={:?} max_waiting={:?}",
cfg.window,
cfg.max_batch_size.get(),
cfg.timeout,
cfg.concurrency_limit,
cfg.max_waiting,
);

let loader = SquareLoader;
let (a, b) = (loader.key(&7), loader.key(&7));
println!("key(7) = {a}, key(7) = {b} -> equal inputs share one batch entry");
}
63 changes: 63 additions & 0 deletions src/collector.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use std::collections::HashMap;
use std::future::Future;
use std::hash::Hash;

// Clone: loader and dispatcher hold separate clones -
// key() must be pure, shared state behind an Arc.
pub trait BatchCollector: Clone + Send + Sync + 'static {
type Input: Send + 'static;
type Output: Send + Clone + 'static;
type Key: Hash + Eq + Send + Clone + 'static;
type Error: std::error::Error + Send + Sync + Clone + 'static;

fn key(&self, input: &Self::Input) -> Self::Key;

// batch arrives deduplicated: inputs sharing a key are interchangeable,
// only one representative reaches load.
// strict contract: every requested key must get a value;
// a missing key is Error::MissingOutput for its waiters,
// an unknown key in the response is Error::ContractViolation
// for the whole batch (it takes precedence when both occur).
// absence semantics live in the implementor's Output type.
fn load(
&self,
batch: HashMap<Self::Key, Self::Input>,
) -> impl Future<Output = Result<HashMap<Self::Key, Self::Output>, Self::Error>> + Send;
}

#[cfg(test)]
mod tests {
use super::*;

#[derive(Clone)]
struct SquareLoader;

impl BatchCollector for SquareLoader {
type Input = u64;
type Output = u64;
type Key = u64;
type Error = std::convert::Infallible;

fn key(&self, input: &u64) -> u64 {
*input
}

async fn load(&self, batch: HashMap<u64, u64>) -> Result<HashMap<u64, u64>, Self::Error> {
Ok(batch.into_iter().map(|(k, x)| (k, x * x)).collect())
}
}

// Spawning the load future proves it is `Send` (the whole point of the
// RPITIT + Send form) and that a plain `async fn` satisfies the bound.
#[tokio::test]
async fn load_future_is_send_and_key_addressed() {
let loader = SquareLoader;
let batch = HashMap::from([(2, 2), (3, 3), (4, 4)]);
let out = tokio::spawn(async move { loader.load(batch).await })
.await
.expect("task joins")
.expect("load succeeds");

assert_eq!(out, HashMap::from([(2, 4), (3, 9), (4, 16)]));
}
}
41 changes: 41 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use std::num::NonZeroUsize;
use std::time::Duration;

#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct BatchLoaderConfig {
pub window: Duration,
pub max_batch_size: NonZeroUsize,
pub timeout: Duration,
pub concurrency_limit: Option<NonZeroUsize>,
// only consulted when concurrency_limit is set
pub max_waiting: Option<Duration>,
}

impl Default for BatchLoaderConfig {
fn default() -> Self {
Self {
window: Duration::from_millis(30),
max_batch_size: NonZeroUsize::new(1024).expect("1024 is non-zero"),
timeout: Duration::from_secs(30),
concurrency_limit: None,
max_waiting: None,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn default_config_matches_documented_values() {
let cfg = BatchLoaderConfig::default();

assert_eq!(cfg.window, Duration::from_millis(30));
assert_eq!(cfg.max_batch_size.get(), 1024);
assert_eq!(cfg.timeout, Duration::from_secs(30));
assert_eq!(cfg.concurrency_limit, None);
assert_eq!(cfg.max_waiting, None);
}
}
Loading