atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 159f0998263b

tarball

atagen <boss@atagen.co>2026-10-12 11:06 UTC

15 files changed+635-92
http: give each run its own lock and keep the dead in sqlite

One mutex over every run serialised the whole server behind whichever
turn was slowest, and under the model oracle that turn was an HTTP call.
Runs now sit behind their own locks in a table that is only held for
lookups, and model turns move to the blocking pool. Finished, cured and
idle-evicted runs are written to a WAL-mode SQLite file (JELQD_DB), so the
graveyard, the leaderboard and a dead run's 410 survive a restart, and
ended runs leave memory at once.
Modified file: .gitignore+1 additions
@@ -1,2 +1,3 @@
11 /target
22 /result
3+/jelqd.sqlite*
Modified file: Cargo.lock+215 additions
@@ -88,12 +88,28 @@ dependencies = [
8888 ]
8989
9090 [[package]]
91+name = "bumpalo"
92+version = "3.20.3"
93+source = "registry+https://github.com/rust-lang/crates.io-index"
94+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
95+
96+[[package]]
9197 name = "bytes"
9298 version = "1.12.1"
9399 source = "registry+https://github.com/rust-lang/crates.io-index"
94100 checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
95101
96102 [[package]]
103+name = "cc"
104+version = "1.8.0"
105+source = "registry+https://github.com/rust-lang/crates.io-index"
106+checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
107+dependencies = [
108+ "find-msvc-tools",
109+ "shlex",
110+]
111+
112+[[package]]
97113 name = "cfg-if"
98114 version = "1.0.5"
99115 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -150,6 +166,24 @@ dependencies = [
150166 ]
151167
152168 [[package]]
169+name = "fallible-iterator"
170+version = "0.3.0"
171+source = "registry+https://github.com/rust-lang/crates.io-index"
172+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
173+
174+[[package]]
175+name = "fallible-streaming-iterator"
176+version = "0.1.9"
177+source = "registry+https://github.com/rust-lang/crates.io-index"
178+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
179+
180+[[package]]
181+name = "find-msvc-tools"
182+version = "0.1.14"
183+source = "registry+https://github.com/rust-lang/crates.io-index"
184+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
185+
186+[[package]]
153187 name = "flate2"
154188 version = "1.1.10"
155189 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -161,6 +195,12 @@ dependencies = [
161195 ]
162196
163197 [[package]]
198+name = "foldhash"
199+version = "0.2.0"
200+source = "registry+https://github.com/rust-lang/crates.io-index"
201+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
202+
203+[[package]]
164204 name = "form_urlencoded"
165205 version = "1.2.2"
166206 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -228,6 +268,33 @@ dependencies = [
228268 ]
229269
230270 [[package]]
271+name = "hashbrown"
272+version = "0.16.1"
273+source = "registry+https://github.com/rust-lang/crates.io-index"
274+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
275+dependencies = [
276+ "foldhash",
277+]
278+
279+[[package]]
280+name = "hashbrown"
281+version = "0.17.1"
282+source = "registry+https://github.com/rust-lang/crates.io-index"
283+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
284+dependencies = [
285+ "foldhash",
286+]
287+
288+[[package]]
289+name = "hashlink"
290+version = "0.12.2"
291+source = "registry+https://github.com/rust-lang/crates.io-index"
292+checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
293+dependencies = [
294+ "hashbrown 0.17.1",
295+]
296+
297+[[package]]
231298 name = "hmac"
232299 version = "0.12.1"
233300 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -437,6 +504,7 @@ dependencies = [
437504 "hyper",
438505 "hyper-util",
439506 "rand",
507+ "rusqlite",
440508 "serde",
441509 "serde_json",
442510 "sha2",
@@ -447,12 +515,33 @@ dependencies = [
447515 ]
448516
449517 [[package]]
518+name = "js-sys"
519+version = "0.3.106"
520+source = "registry+https://github.com/rust-lang/crates.io-index"
521+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
522+dependencies = [
523+ "cfg-if",
524+ "wasm-bindgen",
525+]
526+
527+[[package]]
450528 name = "libc"
451529 version = "0.2.190"
452530 source = "registry+https://github.com/rust-lang/crates.io-index"
453531 checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
454532
455533 [[package]]
534+name = "libsqlite3-sys"
535+version = "0.38.2"
536+source = "registry+https://github.com/rust-lang/crates.io-index"
537+checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
538+dependencies = [
539+ "cc",
540+ "pkg-config",
541+ "vcpkg",
542+]
543+
544+[[package]]
456545 name = "litemap"
457546 version = "0.8.3"
458547 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -522,6 +611,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
522611 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
523612
524613 [[package]]
614+name = "pkg-config"
615+version = "0.3.34"
616+source = "registry+https://github.com/rust-lang/crates.io-index"
617+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
618+
619+[[package]]
525620 name = "potential_utf"
526621 version = "0.1.6"
527622 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -593,6 +688,37 @@ dependencies = [
593688 ]
594689
595690 [[package]]
691+name = "rsqlite-vfs"
692+version = "0.1.1"
693+source = "registry+https://github.com/rust-lang/crates.io-index"
694+checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
695+dependencies = [
696+ "hashbrown 0.16.1",
697+ "thiserror",
698+]
699+
700+[[package]]
701+name = "rusqlite"
702+version = "0.40.2"
703+source = "registry+https://github.com/rust-lang/crates.io-index"
704+checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
705+dependencies = [
706+ "bitflags",
707+ "fallible-iterator",
708+ "fallible-streaming-iterator",
709+ "hashlink",
710+ "libsqlite3-sys",
711+ "smallvec",
712+ "sqlite-wasm-rs",
713+]
714+
715+[[package]]
716+name = "rustversion"
717+version = "1.0.23"
718+source = "registry+https://github.com/rust-lang/crates.io-index"
719+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
720+
721+[[package]]
596722 name = "ryu"
597723 version = "1.0.23"
598724 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -676,6 +802,12 @@ dependencies = [
676802 ]
677803
678804 [[package]]
805+name = "shlex"
806+version = "2.0.1"
807+source = "registry+https://github.com/rust-lang/crates.io-index"
808+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
809+
810+[[package]]
679811 name = "simd-adler32"
680812 version = "0.3.10"
681813 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -704,6 +836,18 @@ dependencies = [
704836 ]
705837
706838 [[package]]
839+name = "sqlite-wasm-rs"
840+version = "0.5.5"
841+source = "registry+https://github.com/rust-lang/crates.io-index"
842+checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
843+dependencies = [
844+ "cc",
845+ "js-sys",
846+ "rsqlite-vfs",
847+ "wasm-bindgen",
848+]
849+
850+[[package]]
707851 name = "stable_deref_trait"
708852 version = "1.2.1"
709853 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -755,6 +899,26 @@ dependencies = [
755899 ]
756900
757901 [[package]]
902+name = "thiserror"
903+version = "2.0.21"
904+source = "registry+https://github.com/rust-lang/crates.io-index"
905+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
906+dependencies = [
907+ "thiserror-impl",
908+]
909+
910+[[package]]
911+name = "thiserror-impl"
912+version = "2.0.21"
913+source = "registry+https://github.com/rust-lang/crates.io-index"
914+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
915+dependencies = [
916+ "proc-macro2",
917+ "quote",
918+ "syn 3.0.7",
919+]
920+
921+[[package]]
758922 name = "tinystr"
759923 version = "0.8.4"
760924 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -901,6 +1065,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
9011065 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
9021066
9031067 [[package]]
1068+name = "vcpkg"
1069+version = "0.2.15"
1070+source = "registry+https://github.com/rust-lang/crates.io-index"
1071+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
1072+
1073+[[package]]
9041074 name = "version_check"
9051075 version = "0.9.5"
9061076 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -922,6 +1092,51 @@ dependencies = [
9221092 ]
9231093
9241094 [[package]]
1095+name = "wasm-bindgen"
1096+version = "0.2.129"
1097+source = "registry+https://github.com/rust-lang/crates.io-index"
1098+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
1099+dependencies = [
1100+ "cfg-if",
1101+ "once_cell",
1102+ "rustversion",
1103+ "wasm-bindgen-macro",
1104+ "wasm-bindgen-shared",
1105+]
1106+
1107+[[package]]
1108+name = "wasm-bindgen-macro"
1109+version = "0.2.129"
1110+source = "registry+https://github.com/rust-lang/crates.io-index"
1111+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
1112+dependencies = [
1113+ "quote",
1114+ "wasm-bindgen-macro-support",
1115+]
1116+
1117+[[package]]
1118+name = "wasm-bindgen-macro-support"
1119+version = "0.2.129"
1120+source = "registry+https://github.com/rust-lang/crates.io-index"
1121+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
1122+dependencies = [
1123+ "bumpalo",
1124+ "proc-macro2",
1125+ "quote",
1126+ "syn 3.0.7",
1127+ "wasm-bindgen-shared",
1128+]
1129+
1130+[[package]]
1131+name = "wasm-bindgen-shared"
1132+version = "0.2.129"
1133+source = "registry+https://github.com/rust-lang/crates.io-index"
1134+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
1135+dependencies = [
1136+ "unicode-ident",
1137+]
1138+
1139+[[package]]
9251140 name = "windows-link"
9261141 version = "0.2.1"
9271142 source = "registry+https://github.com/rust-lang/crates.io-index"
Modified file: Cargo.toml+1 additions
@@ -14,6 +14,7 @@ httpdate = "1"
1414 hyper = { version = "1", features = ["http1", "server"] }
1515 hyper-util = { version = "0.1", features = ["tokio"] }
1616 rand = "0.9"
17+rusqlite = { version = "0.40.2", features = ["bundled"] }
1718 serde = { version = "1", features = ["derive"] }
1819 serde_json = "1"
1920 sha2 = "0.10"
Modified file: src/config.rs+9 additions−1 deletions
@@ -1,4 +1,4 @@
1-use std::time::Duration;
1+use std::{path::PathBuf, time::Duration};
22
33 use crate::game::oracle::OracleTemplate;
44 use crate::secret::Secret;
@@ -8,6 +8,8 @@ pub struct Config {
88 pub secret: Secret,
99 pub max_runs: usize,
1010 pub idle_ttl: Duration,
11+ /// Where finished runs are kept. `None` keeps them in memory, which only tests want.
12+ pub db: Option<PathBuf>,
1113 pub(crate) oracle: OracleTemplate,
1214 }
1315
@@ -18,6 +20,7 @@ impl Default for Config {
1820 secret: Secret::random(),
1921 max_runs: 10_000,
2022 idle_ttl: Duration::from_secs(6 * 60 * 60),
23+ db: None,
2124 oracle: OracleTemplate::Rng,
2225 }
2326 }
@@ -29,6 +32,11 @@ impl Config {
2932 let defaults = Self::default();
3033 Self {
3134 addr: var("JELQD_ADDR").unwrap_or(defaults.addr),
35+ db: Some(
36+ var("JELQD_DB")
37+ .unwrap_or_else(|| "jelqd.sqlite".into())
38+ .into(),
39+ ),
3240 secret: var("JELQD_SECRET").map_or(defaults.secret, |s| Secret::new(s.into_bytes())),
3341 oracle: match var("JELQD_ORACLE").as_deref() {
3442 Some("clm") => OracleTemplate::Clm {
Modified file: src/game/body.rs+4 additions−4 deletions
@@ -144,9 +144,9 @@ impl Run {
144144 pub fn girth_respected(&self) -> bool {
145145 self.girth >= GIRTH_RESPECTED
146146 }
147+}
147148
148- /// A cylinder whose circumference is the girth.
149- pub fn volume(&self, length: f64) -> f64 {
150- self.girth * self.girth * length / (4.0 * std::f64::consts::PI)
151- }
149+/// A cylinder whose circumference is the girth.
150+pub fn volume(length: f64, girth: f64) -> f64 {
151+ girth * girth * length / (4.0 * std::f64::consts::PI)
152152 }
Modified file: src/game/clinic.rs+2 additions−2 deletions
@@ -1,7 +1,7 @@
11 use axum::http::{StatusCode, header};
22 use serde_json::json;
33
4-use super::{Floor, Outcome, Run, etag_matches, parse_credentials, round2};
4+use super::{Floor, Outcome, Run, body::volume, etag_matches, parse_credentials, round2};
55 use crate::{
66 headers::{SERVER_TIMING, X_DIAGNOSIS},
77 problem::Problem,
@@ -63,7 +63,7 @@ impl Run {
6363 self.shame += 15;
6464 }
6565 }
66- let volume = self.volume(self.length);
66+ let volume = volume(self.length, self.girth);
6767 let foreskin = self.foreskin.unwrap_or(0.0);
6868 let timing = format!(
6969 "length;dur={:.2}, girth;dur={:.2}, foreskin;dur={foreskin:.2}, volume;desc=\"cubic inches\";dur={volume:.2}",
Modified file: src/game/mod.rs+1 additions−1 deletions
@@ -15,7 +15,7 @@ mod techniques;
1515 mod turn;
1616 mod utility;
1717
18-pub use body::PatchOp;
18+pub use body::{PatchOp, volume};
1919 pub use forum::{ELDER_LAST_MODIFIED, FORUM_THREAD, forum_thread};
2020 pub use items::{Item, Items, SHOP};
2121 pub use outcome::{Outcome, Payload};
Modified file: src/game/oracle.rs+6 additions
@@ -26,6 +26,12 @@ pub(crate) enum OracleTemplate {
2626 Clm { url: String },
2727 }
2828
29+impl OracleTemplate {
30+ pub(crate) fn blocks(&self) -> bool {
31+ matches!(self, Self::Clm { .. })
32+ }
33+}
34+
2935 pub(crate) struct Clm {
3036 url: String,
3137 agent: ureq::Agent,
Modified file: src/http/meta.rs+42 additions−46 deletions
@@ -1,7 +1,5 @@
11 //! Resources that do not belong to a single run.
22
3-use std::time::Instant;
4-
53 use axum::{
64 Json,
75 http::{StatusCode, header},
@@ -17,11 +15,14 @@ use super::{
1715 run,
1816 };
1917 use crate::{
20- game::{ELDER_LAST_MODIFIED, FORUM_THREAD, Run, balance::MM_PER_INCH, forum_thread},
18+ game::{ELDER_LAST_MODIFIED, FORUM_THREAD, Run, balance::MM_PER_INCH, forum_thread, volume},
2119 headers::X_SEED,
2220 problem::{PROBLEM_BASE, Problem},
21+ records::Record,
2322 };
2423
24+use super::registry::hold;
25+
2526 pub static INDEX: Resource<Shared> = Resource::new(&[Verb::new("GET", index)]);
2627 pub static RUNS: Resource<Shared> = Resource::new(&[Verb::new("POST", create_run)]);
2728 pub static THREAD: Resource<Shared> = Resource::new(&[Verb::new("GET", thread)]);
@@ -68,21 +69,10 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
6869 }
6970 };
7071 let config = &state.config;
71- let mut runs = state.runs();
72- runs.retain(|_, run| ctx.now.duration_since(run.last_seen) < config.idle_ttl);
73- if runs.len() >= config.max_runs {
74- let mut buried: Vec<(Instant, String)> = runs
75- .iter()
76- .filter(|(_, run)| run.ended.is_some())
77- .map(|(id, run)| (run.last_seen, id.clone()))
78- .collect();
79- buried.sort_unstable();
80- let excess = runs.len() + 1 - config.max_runs;
81- for (_, id) in buried.into_iter().take(excess) {
82- runs.remove(&id);
83- }
72+ for slot in state.runs.evict_idle(ctx.now, config.idle_ttl) {
73+ state.records.save(&Record::of(&hold(&slot)));
8474 }
85- if runs.len() >= config.max_runs {
75+ if state.runs.len() >= config.max_runs {
8676 return Problem::new(
8777 StatusCode::SERVICE_UNAVAILABLE,
8878 "server-full",
@@ -93,7 +83,7 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
9383 }
9484 let id = loop {
9585 let id = format!("{:032x}", rand::rng().random::<u128>());
96- if !runs.contains_key(&id) {
86+ if !state.runs.contains(&id) && state.records.find(&id).is_none() {
9787 break id;
9888 }
9989 };
@@ -117,7 +107,7 @@ fn create_run(state: &mut Shared, ctx: &Ctx) -> Response {
117107 )
118108 .into_response();
119109 let response = run::finish(&mut run, ctx, &config.secret, response);
120- runs.insert(id, run);
110+ state.runs.insert(run);
121111 response
122112 }
123113
@@ -146,16 +136,17 @@ fn thread(_: &mut Shared, ctx: &Ctx) -> Response {
146136 }
147137
148138 fn graveyard(state: &mut Shared, _: &Ctx) -> Response {
149- let runs = state.runs();
150- let dead: Vec<Value> = runs
151- .values()
152- .filter_map(|run| {
153- let ending = run.ended.as_ref()?;
139+ let dead: Vec<Value> = state
140+ .records
141+ .dead()
142+ .into_iter()
143+ .filter_map(|record| {
144+ let ending = record.ending?;
154145 Some(json!({
155- "public_id": run.public_id,
156- "cause": format!("{PROBLEM_BASE}{}", ending.cause.slug()),
157- "detail": ending.cause.detail(),
158- "turn": ending.turn,
146+ "public_id": record.public_id,
147+ "cause": format!("{PROBLEM_BASE}{}", ending.cause),
148+ "detail": ending.detail,
149+ "turn": record.turn,
159150 }))
160151 })
161152 .collect();
@@ -206,33 +197,38 @@ fn leaderboard(state: &mut Shared, ctx: &Ctx) -> Response {
206197 }
207198 };
208199 let include_seeded = ctx.query("seeded") == Some("include");
209- let runs = state.runs();
210- let mut entries: Vec<&Run> = runs
211- .values()
212- .filter(|run| include_seeded || !run.seeded)
213- .collect();
214- let key = |run: &Run| match sort {
215- Sort::SelfReported => run.self_reported(),
216- Sort::ContentLength => run.current_length(),
217- Sort::Volume => run.volume(run.self_reported()),
200+ let mut entries: Vec<Record> = state.runs.each(Record::of);
201+ entries.extend(
202+ state
203+ .records
204+ .all()
205+ .into_iter()
206+ .filter(|record| !state.runs.contains(&record.id)),
207+ );
208+ entries.retain(|record| include_seeded || !record.seeded);
209+ let key = |record: &Record| match sort {
210+ Sort::SelfReported => record.self_reported,
211+ Sort::ContentLength => record.length,
212+ Sort::Volume => volume(record.self_reported, record.girth),
218213 };
219214 entries.sort_by(|a, b| key(b).total_cmp(&key(a)));
220215 let entries: Vec<Value> = entries
221216 .into_iter()
222- .map(|run| {
217+ .map(|record| {
223218 let mut entry = json!({
224- "public_id": run.public_id,
225- "status": run.status(),
226- "floor": run.floor.number(),
227- "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,
228- "ranked": !run.seeded,
229- "viral": run.filmed_at.is_some(),
219+ "public_id": record.public_id,
220+ "status": record.status,
221+ "floor": record.floor,
222+ "self_reported_length": (record.self_reported * 100.0).round() / 100.0,
223+ "ranked": !record.seeded,
224+ "viral": record.viral,
230225 });
231226 if sort == Sort::Volume {
232- entry["volume"] = json!((run.volume(run.self_reported()) * 100.0).round() / 100.0);
227+ entry["volume"] =
228+ json!((volume(record.self_reported, record.girth) * 100.0).round() / 100.0);
233229 }
234230 if sort == Sort::ContentLength {
235- entry["content_length"] = json!((run.current_length() * MM_PER_INCH).round());
231+ entry["content_length"] = json!((record.length * MM_PER_INCH).round());
236232 }
237233 entry
238234 })
Modified file: src/http/mod.rs+37 additions−23 deletions
@@ -1,52 +1,54 @@
11 mod ctx;
22 mod meta;
33 mod middleware;
4+mod registry;
45 mod resource;
56 mod response;
67 mod run;
78 mod runs;
89 mod ui;
910
10-use std::{
11- collections::HashMap,
12- sync::{Arc, Mutex, MutexGuard, PoisonError},
13-};
11+use std::sync::Arc;
1412
1513 use axum::{
1614 Router,
1715 extract::State,
18- response::Redirect,
16+ response::{Redirect, Response},
1917 routing::{MethodRouter, any},
2018 };
2119
2220 use tower_http::catch_panic::CatchPanicLayer;
2321
24-use crate::{config::Config, game::Run};
22+use crate::{config::Config, records::Records};
2523 use ctx::Ctx;
24+use registry::Registry;
2625 use resource::Resource;
2726 use run::RunResource;
2827
2928 pub struct AppState {
30- runs: Mutex<HashMap<String, Run>>,
29+ runs: Registry,
30+ records: Records,
3131 config: Config,
3232 }
3333
3434 impl AppState {
3535 pub fn new(config: Config) -> Self {
36+ let records = match &config.db {
37+ Some(path) => Records::open(path).unwrap_or_else(|e| {
38+ panic!("the records at {} could not be opened: {e}", path.display())
39+ }),
40+ None => Records::in_memory(),
41+ };
3642 Self {
37- runs: Mutex::default(),
43+ runs: Registry::default(),
44+ records,
3845 config,
3946 }
4047 }
4148
42- /// A panic mid-request must not take every other run down with it, so a poisoned lock is recovered.
43- fn runs(&self) -> MutexGuard<'_, HashMap<String, Run>> {
44- self.runs.lock().unwrap_or_else(PoisonError::into_inner)
45- }
46-
4749 #[cfg(test)]
48- pub(crate) fn insert(&self, run: Run) {
49- self.runs().insert(run.id.clone(), run);
50+ pub(crate) fn insert(&self, run: crate::game::Run) {
51+ self.runs.insert(run);
5052 }
5153 }
5254
@@ -74,17 +76,29 @@ pub fn router(state: Shared) -> Router {
7476 }
7577
7678 fn global(resource: &'static Resource<Shared>) -> MethodRouter<Shared> {
77- any(
78- move |State(mut state): State<Shared>, ctx: Ctx| async move {
79- resource.dispatch(&mut state, &ctx)
80- },
81- )
79+ any(move |State(state): State<Shared>, ctx: Ctx| async move {
80+ offload(state, move |mut state| resource.dispatch(&mut state, &ctx)).await
81+ })
8282 }
8383
8484 fn scoped(resource: &'static RunResource) -> MethodRouter<Shared> {
85- any(
86- move |State(state): State<Shared>, ctx: Ctx| async move { run::serve(&state, ctx, resource) },
87- )
85+ any(move |State(state): State<Shared>, ctx: Ctx| async move {
86+ offload(state, move |state| run::serve(&state, ctx, resource)).await
87+ })
88+}
89+
90+/// The model oracle blocks on HTTP for every roll, so its turns run on the blocking pool and
91+/// leave the async workers free. Dice are quick enough to roll in place.
92+async fn offload<F>(state: Shared, work: F) -> Response
93+where
94+ F: FnOnce(Shared) -> Response + Send + 'static,
95+{
96+ if !state.config.oracle.blocks() {
97+ return work(state);
98+ }
99+ tokio::task::spawn_blocking(move || work(state))
100+ .await
101+ .unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic()))
88102 }
89103
90104 fn api(state: Shared) -> Router {
Added file: src/http/registry.rs+77 additions
@@ -0,0 +1,77 @@
1+//! The live runs. The table is only locked long enough to find a run, and each run has its own
2+//! lock, so one slow turn never holds up anyone else's.
3+
4+use std::{
5+ collections::HashMap,
6+ sync::{Arc, Mutex, MutexGuard, PoisonError, RwLock, TryLockError},
7+ time::{Duration, Instant},
8+};
9+
10+use crate::game::Run;
11+
12+pub type Slot = Arc<Mutex<Run>>;
13+
14+#[derive(Default)]
15+pub struct Registry {
16+ runs: RwLock<HashMap<String, Slot>>,
17+}
18+
19+/// A panic mid-request must not take the run down with it, so a poisoned lock is recovered.
20+pub fn hold(slot: &Slot) -> MutexGuard<'_, Run> {
21+ slot.lock().unwrap_or_else(PoisonError::into_inner)
22+}
23+
24+impl Registry {
25+ pub fn get(&self, id: &str) -> Option<Slot> {
26+ self.read().get(id).cloned()
27+ }
28+
29+ pub fn len(&self) -> usize {
30+ self.read().len()
31+ }
32+
33+ pub fn insert(&self, run: Run) {
34+ let id = run.id.clone();
35+ self.write().insert(id, Arc::new(Mutex::new(run)));
36+ }
37+
38+ pub fn remove(&self, id: &str) {
39+ self.write().remove(id);
40+ }
41+
42+ pub fn contains(&self, id: &str) -> bool {
43+ self.read().contains_key(id)
44+ }
45+
46+ /// Every live run, each visited under its own lock.
47+ pub fn each<T>(&self, mut visit: impl FnMut(&Run) -> T) -> Vec<T> {
48+ let slots: Vec<Slot> = self.read().values().cloned().collect();
49+ slots.iter().map(|slot| visit(&hold(slot))).collect()
50+ }
51+
52+ /// Takes out the runs nobody has touched for `ttl`, and hands them back for the record.
53+ pub fn evict_idle(&self, now: Instant, ttl: Duration) -> Vec<Slot> {
54+ let mut runs = self.write();
55+ let idle: Vec<String> = runs
56+ .iter()
57+ .filter(|(_, slot)| {
58+ let idle = |run: &Run| now.duration_since(run.last_seen) >= ttl;
59+ match slot.try_lock() {
60+ Ok(run) => idle(&run),
61+ Err(TryLockError::Poisoned(poisoned)) => idle(&poisoned.into_inner()),
62+ Err(TryLockError::WouldBlock) => false,
63+ }
64+ })
65+ .map(|(id, _)| id.clone())
66+ .collect();
67+ idle.iter().filter_map(|id| runs.remove(id)).collect()
68+ }
69+
70+ fn read(&self) -> std::sync::RwLockReadGuard<'_, HashMap<String, Slot>> {
71+ self.runs.read().unwrap_or_else(PoisonError::into_inner)
72+ }
73+
74+ fn write(&self) -> std::sync::RwLockWriteGuard<'_, HashMap<String, Slot>> {
75+ self.runs.write().unwrap_or_else(PoisonError::into_inner)
76+ }
77+}
Modified file: src/http/run.rs+38 additions−15 deletions
@@ -7,7 +7,9 @@ use axum::{
77 use hyper_util::rt::TokioIo;
88 use tokio::io::AsyncWriteExt;
99
10-use super::{AppState, ctx::Ctx, meta, middleware::Compress, resource::Resource, runs};
10+use super::{
11+ AppState, ctx::Ctx, meta, middleware::Compress, registry::hold, resource::Resource, runs,
12+};
1113 use crate::{
1214 game::{
1315 Elder, FORUM_THREAD, Floor, Run, SHOP, Story,
@@ -19,6 +21,7 @@ use crate::{
1921 },
2022 locale::Locale,
2123 problem::{PROBLEM_BASE, Problem},
24+ records::Record,
2225 secret::Secret,
2326 };
2427
@@ -41,31 +44,51 @@ pub struct RunResource {
4144 pub struct Greeting(pub String);
4245
4346 pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response {
44- let mut runs = state.runs();
45- let Some(run) = runs.get_mut(ctx.param("id")) else {
46- return Problem::new(
47- StatusCode::NOT_FOUND,
48- "no-such-run",
49- "There is no such run. There may never have been.",
50- )
51- .into_response();
47+ let id = ctx.param("id").to_owned();
48+ let Some(slot) = state.runs.get(&id) else {
49+ return match state.records.find(&id) {
50+ Some(Record {
51+ ending: Some(ending),
52+ turn,
53+ ..
54+ }) => gone(&ending.cause, turn),
55+ _ => Problem::new(
56+ StatusCode::NOT_FOUND,
57+ "no-such-run",
58+ "There is no such run. There may never have been.",
59+ )
60+ .into_response(),
61+ };
5262 };
63+ let mut guard = hold(&slot);
64+ let run = &mut *guard;
5365 if let Some(ending) = &run.ended {
54- return Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")
55- .with("cause", format!("{PROBLEM_BASE}{}", ending.cause.slug()))
56- .with("turn", ending.turn)
57- .with("graveyard", "/graveyard")
58- .into_response();
66+ return gone(ending.cause.slug(), ending.turn);
5967 }
6068 run.last_seen = ctx.now;
6169 run.observed = None;
6270 let response =
6371 gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx));
6472 let response = finish(run, &ctx, &state.config.secret, response);
65- drop(runs);
73+ if run.ended.is_some() || run.clinic.cured {
74+ state.records.save(&Record::of(run));
75+ }
76+ let ended = run.ended.is_some();
77+ drop(guard);
78+ if ended {
79+ state.runs.remove(&id);
80+ }
6681 greet(response, &mut ctx)
6782 }
6883
84+fn gone(cause: &str, turn: u32) -> Response {
85+ Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")
86+ .with("cause", format!("{PROBLEM_BASE}{cause}"))
87+ .with("turn", turn)
88+ .with("graveyard", "/graveyard")
89+ .into_response()
90+}
91+
6992 fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> {
7093 if scope == Scope::Pockets {
7194 return None;
Modified file: src/lib.rs+1 additions
@@ -4,6 +4,7 @@ mod headers;
44 mod http;
55 mod locale;
66 mod problem;
7+mod records;
78 mod secret;
89
910 #[cfg(test)]
Added file: src/records.rs+176 additions
@@ -0,0 +1,176 @@
1+//! Finished and finishing runs, kept in SQLite so the graveyard and the leaderboard outlive the process.
2+
3+use std::{
4+ path::Path,
5+ sync::{Mutex, MutexGuard, PoisonError},
6+ time::{SystemTime, UNIX_EPOCH},
7+};
8+
9+use rusqlite::{Connection, OptionalExtension, Row, params};
10+
11+use crate::game::Run;
12+
13+const SCHEMA: &str = "
14+CREATE TABLE IF NOT EXISTS runs (
15+ id TEXT PRIMARY KEY,
16+ public_id TEXT NOT NULL,
17+ seeded INTEGER NOT NULL,
18+ status TEXT NOT NULL,
19+ cause TEXT,
20+ detail TEXT,
21+ turn INTEGER NOT NULL,
22+ floor INTEGER NOT NULL,
23+ length REAL NOT NULL,
24+ self_reported REAL NOT NULL,
25+ girth REAL NOT NULL,
26+ viral INTEGER NOT NULL,
27+ recorded_at INTEGER NOT NULL
28+);
29+CREATE INDEX IF NOT EXISTS runs_by_cause ON runs (cause);
30+";
31+
32+/// What the graveyard and the leaderboard need to know about a run, live or long gone.
33+pub struct Record {
34+ pub id: String,
35+ pub public_id: String,
36+ pub seeded: bool,
37+ pub status: String,
38+ pub ending: Option<Ending>,
39+ pub turn: u32,
40+ pub floor: u8,
41+ pub length: f64,
42+ pub self_reported: f64,
43+ pub girth: f64,
44+ pub viral: bool,
45+}
46+
47+pub struct Ending {
48+ pub cause: String,
49+ pub detail: String,
50+}
51+
52+impl Record {
53+ pub fn of(run: &Run) -> Self {
54+ Self {
55+ id: run.id.clone(),
56+ public_id: run.public_id.clone(),
57+ seeded: run.seeded,
58+ status: run.status().to_owned(),
59+ ending: run.ended.as_ref().map(|ending| Ending {
60+ cause: ending.cause.slug().to_owned(),
61+ detail: ending.cause.detail().to_owned(),
62+ }),
63+ turn: run.ended.as_ref().map_or(run.turn, |ending| ending.turn),
64+ floor: run.floor.number(),
65+ length: run.current_length(),
66+ self_reported: run.self_reported(),
67+ girth: run.girth,
68+ viral: run.filmed_at.is_some(),
69+ }
70+ }
71+
72+ fn from_row(row: &Row) -> rusqlite::Result<Self> {
73+ let cause: Option<String> = row.get("cause")?;
74+ let detail: Option<String> = row.get("detail")?;
75+ Ok(Self {
76+ id: row.get("id")?,
77+ public_id: row.get("public_id")?,
78+ seeded: row.get("seeded")?,
79+ status: row.get("status")?,
80+ ending: cause
81+ .zip(detail)
82+ .map(|(cause, detail)| Ending { cause, detail }),
83+ turn: row.get("turn")?,
84+ floor: row.get("floor")?,
85+ length: row.get("length")?,
86+ self_reported: row.get("self_reported")?,
87+ girth: row.get("girth")?,
88+ viral: row.get("viral")?,
89+ })
90+ }
91+}
92+
93+pub struct Records {
94+ db: Mutex<Connection>,
95+}
96+
97+impl Records {
98+ /// WAL lets the leaderboard read while a run is being buried.
99+ pub fn open(path: &Path) -> rusqlite::Result<Self> {
100+ let db = Connection::open(path)?;
101+ db.pragma_update(None, "journal_mode", "WAL")?;
102+ db.pragma_update(None, "synchronous", "NORMAL")?;
103+ Self::with(db)
104+ }
105+
106+ pub fn in_memory() -> Self {
107+ Self::with(Connection::open_in_memory().expect("an in-memory database always opens"))
108+ .expect("the schema applies to an empty database")
109+ }
110+
111+ fn with(db: Connection) -> rusqlite::Result<Self> {
112+ db.execute_batch(SCHEMA)?;
113+ Ok(Self { db: Mutex::new(db) })
114+ }
115+
116+ fn db(&self) -> MutexGuard<'_, Connection> {
117+ self.db.lock().unwrap_or_else(PoisonError::into_inner)
118+ }
119+
120+ /// A record that cannot be written is a 500, the same as any other broken promise to persist.
121+ pub fn save(&self, record: &Record) {
122+ let recorded_at = SystemTime::now()
123+ .duration_since(UNIX_EPOCH)
124+ .map_or(0, |since| {
125+ i64::try_from(since.as_secs()).unwrap_or(i64::MAX)
126+ });
127+ self.db()
128+ .execute(
129+ "INSERT OR REPLACE INTO runs (id, public_id, seeded, status, cause, detail, turn,
130+ floor, length, self_reported, girth, viral, recorded_at)
131+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
132+ params![
133+ record.id,
134+ record.public_id,
135+ record.seeded,
136+ record.status,
137+ record.ending.as_ref().map(|e| &e.cause),
138+ record.ending.as_ref().map(|e| &e.detail),
139+ record.turn,
140+ record.floor,
141+ record.length,
142+ record.self_reported,
143+ record.girth,
144+ record.viral,
145+ recorded_at,
146+ ],
147+ )
148+ .unwrap_or_else(|e| panic!("run {} could not be recorded: {e}", record.id));
149+ }
150+
151+ pub fn find(&self, id: &str) -> Option<Record> {
152+ self.db()
153+ .query_row("SELECT * FROM runs WHERE id = ?1", [id], Record::from_row)
154+ .optional()
155+ .unwrap_or_else(|e| panic!("the records could not be read: {e}"))
156+ }
157+
158+ pub fn dead(&self) -> Vec<Record> {
159+ self.query("SELECT * FROM runs WHERE cause IS NOT NULL ORDER BY recorded_at")
160+ }
161+
162+ pub fn all(&self) -> Vec<Record> {
163+ self.query("SELECT * FROM runs")
164+ }
165+
166+ fn query(&self, sql: &str) -> Vec<Record> {
167+ let db = self.db();
168+ let mut statement = db
169+ .prepare_cached(sql)
170+ .unwrap_or_else(|e| panic!("the records could not be read: {e}"));
171+ statement
172+ .query_map([], Record::from_row)
173+ .and_then(Iterator::collect)
174+ .unwrap_or_else(|e| panic!("the records could not be read: {e}"))
175+ }
176+}
Modified file: src/tests.rs+25 additions
@@ -2056,3 +2056,28 @@ async fn the_forum_respects_volume() {
20562056 assert_eq!(board["entries"][0]["public_id"], "stout");
20572057 assert!(board["entries"][0]["volume"].as_f64().unwrap() > 0.0);
20582058 }
2059+
2060+#[tokio::test]
2061+async fn the_dead_outlive_the_server() {
2062+ let path = std::env::temp_dir().join(format!("jelqd-{}.sqlite", std::process::id()));
2063+ let config = || Config {
2064+ db: Some(path.clone()),
2065+ ..Config::default()
2066+ };
2067+ let first = crate::app(config());
2068+ let created = send(&first, "POST", "/runs", &[], "").await;
2069+ let location = header(&created, "location").to_owned();
2070+ send(&first, "DELETE", &location, &[], "").await;
2071+ drop(first);
2072+
2073+ let second = crate::app(config());
2074+ let gone = send(&second, "GET", &location, &[], "").await;
2075+ assert_eq!(gone.status(), StatusCode::GONE);
2076+ let graveyard = json(send(&second, "GET", "/graveyard", &[], "").await).await;
2077+ assert_eq!(graveyard["runs"].as_array().unwrap().len(), 1);
2078+ let board = json(send(&second, "GET", "/leaderboard", &[], "").await).await;
2079+ assert_eq!(board["entries"][0]["status"], "gone");
2080+ for suffix in ["", "-wal", "-shm"] {
2081+ let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
2082+ }
2083+}