Skip to content

Commit aae6fe4

Browse files
committed
v0.2.1
1 parent 3f22aed commit aae6fe4

45 files changed

Lines changed: 2258 additions & 719 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 23 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,16 @@ celld-ltx = { path = "crates/ltx" }
4343
chrono = { version = "0.4", default-features = false, features = ["std"] }
4444
ed25519-dalek = { version = "2", features = ["pkcs8"] }
4545
encoding_rs = "0.8"
46-
fastwebsockets = { version = "0.8", default-features = false, features = ["upgrade", "with_axum"] }
46+
# unstable-split gives the read and write halves as separate objects. The
47+
# tunnel needs them: a WebSocket read is not cancel-safe, so a live read
48+
# future must survive while the same socket is written, and that is only
49+
# possible when the halves are not one borrow. See tests/p0/runtime/tunnel_cancel.rs.
50+
#
51+
# The feature is unstable by name, so a 0.8.x release can rename or drop it
52+
# without upstream calling that a break, and a caret range would take it. Deno
53+
# Land maintains fastwebsockets, so celld carries that risk knowingly; the
54+
# version is exact to keep the upgrade a decision rather than a surprise.
55+
fastwebsockets = { version = "=0.8.1", default-features = false, features = ["upgrade", "with_axum", "unstable-split"] }
4756
flate2 = "1"
4857
futures-util = "0.3"
4958
getrandom = "0.3"
@@ -58,7 +67,7 @@ md-5 = "0.10"
5867
object_store = { version = "0.11", default-features = false }
5968
# The native column-writer API only: no `arrow` feature, so the arrow
6069
# crate stack stays out of the binary. Revisit only if the telemetry
61-
# schema ever grows nested columns (wiki/designs/otel.md).
70+
# schema ever grows nested columns.
6271
parquet = { version = "59.2.0", default-features = false, features = ["zstd"] }
6372
p256 = { version = "0.13", features = ["ecdh", "ecdsa", "pkcs8"] }
6473
# The other NIST curves the Workers key surface accepts. Parsing and JWK
@@ -92,10 +101,18 @@ serde = { version = "1", features = ["derive"] }
92101
serde_json = "1"
93102
sha1 = "0.10"
94103
sha2 = "0.10"
104+
# sqlite-vec is pre-v1, and its Rust binding is outside its compatibility
105+
# policy. Pin the audited C amalgamation exactly.
106+
sqlite-vec = "=0.1.9"
95107
subtle = "2"
96108
tempfile = "3"
97109
thiserror = "2"
98-
tikv-jemallocator = "0.6"
110+
# `stats` is what makes `stats.allocated` and `stats.resident` readable. The
111+
# pressure classifier needs their difference: the pages jemalloc keeps but no
112+
# cell uses. Without it a node that sheds its whole working set stays latched
113+
# on an RSS number the allocator, not celld, controls.
114+
tikv-jemalloc-ctl = { version = "0.6", features = ["stats"] }
115+
tikv-jemallocator = { version = "0.6", features = ["stats"] }
99116
tokio = "1"
100117
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
101118
tokio-stream = "0.1"
@@ -105,7 +122,7 @@ tracing-appender = "0.2"
105122
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
106123
url = "2"
107124
urlpattern = "=0.4.2"
108-
# D1 (wiki/designs/isolate-threading.md) needs v8::Locker and Send Globals:
125+
# Shared isolates need v8::Locker and Send Globals:
109126
# denoland/rusty_v8#2044 and #2045. Both ship in 152.1.0, so this is an
110127
# ordinary dependency again — no path override, and no hand-built archive.
111128
v8 = "152.1"

README.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,40 @@ celld --bucket s3://my-cells-bucket --listen 0.0.0.0:8080 \
153153
--internal-listen 10.0.0.12:8081 --advertise node-a.internal:8081
154154
```
155155

156-
celld enables an RSS threshold at 80% of the available memory by default. Set
156+
celld enables a memory threshold at 80% of the available memory by default. Set
157157
`CELLD_MAX_RSS_MB` to change the threshold, or set it to `0` to disable memory
158-
pressure shedding. Under pressure, celld durably replicates and fences the
159-
least-recently used idle cells. It then publishes the cells as unowned without
160-
resetting their epochs. Those cells become inactive, and celld refuses to
161-
reacquire new unowned cells until RSS reaches 80% of the threshold. A spare
162-
receives no assignment. It acquires a released cell through the same bucket
163-
protocol when normal traffic reaches it. celld does not shed a cell with active
164-
work or a live host WebSocket.
158+
pressure shedding. celld measures the memory that the cells hold, and not the
159+
resident set size of the process. The two differ, because the memory allocator
160+
keeps some freed pages instead of returning them to the operating system.
161+
Shedding a cell cannot return those pages, so a threshold on the resident set
162+
size holds a node in pressure after the node gives every cell back. The `/state`
163+
route reports both numbers.
164+
165+
celld also applies an absolute cap to the resident set size of the process. The
166+
cap is 95% of the available memory. It protects the node when the allocator
167+
holds memory that shedding cannot return, because the operating system stops a
168+
process that uses more memory than the machine has. The node logs a warning when
169+
this cap applies.
170+
171+
The cap is a share of the machine, and celld does not derive it from the
172+
threshold. A `CELLD_MAX_RSS_MB` at or above 95% of the available memory therefore reaches
173+
the cap. The cap is then the effective limit. The node decides on its resident
174+
set size, and celld reports this at startup. `CELLD_MAX_RSS_MB=0`
175+
disables the threshold and the cap together. When celld cannot read the size of
176+
the available memory, it applies a cap of 125% of an explicit threshold.
177+
178+
Under pressure, celld durably replicates and fences the least-recently used idle
179+
cells. It then publishes the cells as unowned without resetting their epochs.
180+
Those cells become inactive, and celld refuses to reacquire new unowned cells.
181+
182+
Each limit releases separately. The threshold releases when the memory in use
183+
falls to 80% of the threshold. The cap releases when the resident set size falls
184+
to 80% of the cap. A crossing of one limit therefore does not hold the node
185+
against the other.
186+
187+
A spare receives no assignment. It acquires a released cell through the same
188+
bucket protocol when normal traffic reaches it. celld does not shed a cell with
189+
active work or a live host WebSocket.
165190

166191
## Contributions
167192

crates/celld/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "celld"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
edition = "2021"
55
rust-version = "1.94.1"
66
license = "Apache-2.0"
@@ -53,6 +53,7 @@ rand.workspace = true
5353
regex.workspace = true
5454
reqwest = { workspace = true, features = ["rustls-tls", "stream", "json"] }
5555
rusqlite = { workspace = true, features = ["backup", "bundled", "hooks"] }
56+
sqlite-vec.workspace = true
5657
rustls.workspace = true
5758
rsa.workspace = true
5859
serde.workspace = true
@@ -61,6 +62,7 @@ sha1.workspace = true
6162
sha2.workspace = true
6263
subtle.workspace = true
6364
tempfile.workspace = true
65+
tikv-jemalloc-ctl.workspace = true
6466
tikv-jemallocator.workspace = true
6567
tokio = { workspace = true, features = ["full"] }
6668
tokio-rustls.workspace = true

crates/celld/bucket.rs

Lines changed: 146 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ impl StorageBackend {
101101
},
102102
}
103103
}
104+
105+
/// What a conditional write sends on the wire, named for an operator
106+
/// reading a probe failure. The dialects use different headers, so a
107+
/// message that names only one sends half the fleet looking in the
108+
/// wrong place.
109+
fn precondition(self) -> &'static str {
110+
match self {
111+
StorageBackend::S3 => "If-Match / If-None-Match",
112+
StorageBackend::Gcs => "x-goog-if-generation-match",
113+
}
114+
}
104115
}
105116

106117
/// One object-store bucket, optionally scoped to a key prefix. Cheap to
@@ -539,6 +550,135 @@ impl Bucket {
539550
}
540551
}
541552
}
553+
554+
/// Run the conditional-write contract against the live bucket.
555+
///
556+
/// A store can accept a precondition header and then ignore it, and no
557+
/// capability API answers whether it does. So the probe provokes the
558+
/// two rejections a conforming store must produce, and checks it
559+
/// produced them. celld decides which node owns a cell with a
560+
/// conditional write, so a store that applies a write it must reject
561+
/// lets two nodes own one cell (denoland/celld#137).
562+
///
563+
/// The two outcomes are separated because they need different
564+
/// responses. A `Violation` is a property of the store and never
565+
/// clears, so it can stop a node. An `Err` is ambiguous — a network
566+
/// fault, a rejected credential — and a retry can clear it, so a
567+
/// caller that must not fail on a transient blip keeps serving.
568+
pub(crate) async fn probe_cas_steps(&self) -> anyhow::Result<CasVerdict> {
569+
let nanos = std::time::SystemTime::now()
570+
.duration_since(std::time::UNIX_EPOCH)
571+
.map(|since| since.as_nanos())
572+
.unwrap_or_default();
573+
// Unique per probe, so several nodes probing at once touch
574+
// disjoint keys and no probe reads another one's object as the
575+
// store misbehaving — a collision surfaces as a false `Violation`,
576+
// which stops a node. The random half carries that alone, because
577+
// a container fleet shares pid 1 and a clock before the epoch
578+
// leaves `nanos` at zero.
579+
let key = format!(
580+
"probe/cas-{nanos}-{}-{:016x}",
581+
std::process::id(),
582+
rand::random::<u64>()
583+
);
584+
let verdict = self.cas_contract(&key).await;
585+
// The object is debris on every path, so retire it before the
586+
// verdict. A delete that fails leaves one tiny object under
587+
// `probe/`, which nothing lists and nothing reads — but a
588+
// credential that cannot delete accrues one per boot, so say so.
589+
if let Err(error) = self.delete(&key).await {
590+
tracing::warn!(%error, "the conditional-write probe could not delete its object");
591+
}
592+
verdict
593+
}
594+
595+
/// [`Self::probe_cas_steps`] collapsed to one answer, where any wrong
596+
/// answer fails the check.
597+
pub async fn probe_cas(&self) -> anyhow::Result<()> {
598+
match self.probe_cas_steps().await? {
599+
CasVerdict::Conformant => Ok(()),
600+
CasVerdict::Violation(reason) => Err(anyhow!(reason)),
601+
}
602+
}
603+
604+
/// The four steps, against one key. Steps 2 and 4 must be rejected;
605+
/// a store that applies either one cannot fence.
606+
async fn cas_contract(&self, key: &str) -> anyhow::Result<CasVerdict> {
607+
let precondition = self.backend.precondition();
608+
let ambiguous = || {
609+
format!(
610+
"the store answered a conditional write with an error where celld requires a \
611+
clean rejection, so celld cannot tell a lost race from a failed write and \
612+
reconciles forever; the store must answer {precondition} with a rejection"
613+
)
614+
};
615+
616+
// 1. A create on an absent key applies, and answers the token that
617+
// steps 3 and 4 need.
618+
let Some(token) = self
619+
.put_cas(key, b"probe-create".to_vec(), None)
620+
.await
621+
.context("the conditional-write probe could not create its object")?
622+
else {
623+
return Ok(CasVerdict::Violation(
624+
"the store rejected a conditional create of an object that does not exist"
625+
.to_string(),
626+
));
627+
};
628+
629+
// 2. A create over the object step 1 wrote must be rejected.
630+
if self
631+
.put_cas(key, b"probe-recreate".to_vec(), None)
632+
.await
633+
.with_context(ambiguous)?
634+
.is_some()
635+
{
636+
return Ok(CasVerdict::Violation(format!(
637+
"the store overwrote an object although the write was conditional on that object \
638+
being absent; the store accepts {precondition} and does not enforce it, so two \
639+
nodes can own one cell"
640+
)));
641+
}
642+
643+
// 3. An update that carries the current token applies, and that
644+
// retires the token step 4 reuses.
645+
if self
646+
.put_cas(key, b"probe-update".to_vec(), Some(&token))
647+
.await
648+
.context("the conditional-write probe could not update its object")?
649+
.is_none()
650+
{
651+
return Ok(CasVerdict::Violation(
652+
"the store rejected a conditional update that carried the current token"
653+
.to_string(),
654+
));
655+
}
656+
657+
// 4. The token is stale now, so the update must be rejected. This
658+
// step is the fencing contract itself.
659+
if self
660+
.put_cas(key, b"probe-stale".to_vec(), Some(&token))
661+
.await
662+
.with_context(ambiguous)?
663+
.is_some()
664+
{
665+
return Ok(CasVerdict::Violation(format!(
666+
"the store applied a conditional write that carried a stale token; the store \
667+
accepts {precondition} and does not enforce it, so two nodes can own one cell"
668+
)));
669+
}
670+
671+
Ok(CasVerdict::Conformant)
672+
}
673+
}
674+
675+
/// What [`Bucket::probe_cas_steps`] found.
676+
pub(crate) enum CasVerdict {
677+
/// The store rejected both writes it had to reject.
678+
Conformant,
679+
/// The store answered wrongly, and the string says how. This never
680+
/// clears on a retry, so a caller can act on it.
681+
Violation(String),
542682
}
543683

544684
/// The replica-lane store for a `gs://` fleet bucket: its own transport
@@ -613,43 +753,15 @@ mod live_cas {
613753
});
614754
let bucket = Bucket::open(&name, endpoint.as_deref(), &region, creds, Some("cas-test"))
615755
.expect("open bucket");
616-
let nanos = std::time::SystemTime::now()
617-
.duration_since(std::time::UNIX_EPOCH)
618-
.unwrap()
619-
.as_nanos();
620-
let key = format!("cas-probe/{nanos}");
621756

622-
// 1. Create on an absent key applies.
623-
let e1 = bucket
624-
.put_cas(&key, b"v1".to_vec(), None)
625-
.await
626-
.expect("create must not error")
627-
.expect("fresh create must apply (Ok(Some))");
628-
// 2. Create over an existing key is cleanly rejected.
629-
assert!(
630-
bucket
631-
.put_cas(&key, b"v1b".to_vec(), None)
632-
.await
633-
.expect("create-again must not error")
634-
.is_none(),
635-
"create over an existing key must be Ok(None)"
636-
);
637-
// 3. Update with the current etag applies.
757+
// The four steps live in `Bucket::probe_cas`, which `celld
758+
// diagnose` and node startup run against an operator's bucket.
759+
// This test points the same code at a real provider, which is the
760+
// one question a mock cannot answer.
638761
bucket
639-
.put_cas(&key, b"v3".to_vec(), Some(&e1))
762+
.probe_cas()
640763
.await
641-
.expect("update must not error")
642-
.expect("update with current etag must apply (Ok(Some))");
643-
// 4. Update with the now-stale etag is cleanly rejected — the fencing case.
644-
assert!(
645-
bucket
646-
.put_cas(&key, b"v4".to_vec(), Some(&e1))
647-
.await
648-
.expect("stale update must not error")
649-
.is_none(),
650-
"update with a stale etag must be Ok(None) — the fencing contract"
651-
);
652-
bucket.delete(&key).await.expect("cleanup delete");
764+
.expect("the store must keep the conditional-write contract");
653765
eprintln!("CAS verified on {name}: create / reject-create / update / reject-stale");
654766
}
655767
}

0 commit comments

Comments
 (0)