tiderace 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. tiderace-0.1.0/Cargo.toml +27 -0
  2. tiderace-0.1.0/PKG-INFO +24 -0
  3. tiderace-0.1.0/README.md +7 -0
  4. tiderace-0.1.0/crates/engine-core/Cargo.toml +12 -0
  5. tiderace-0.1.0/crates/engine-core/src/cache/cache.rs +18 -0
  6. tiderace-0.1.0/crates/engine-core/src/cache/cache_key.rs +187 -0
  7. tiderace-0.1.0/crates/engine-core/src/cache/cached_outcome.rs +33 -0
  8. tiderace-0.1.0/crates/engine-core/src/cache/dir_cache.rs +187 -0
  9. tiderace-0.1.0/crates/engine-core/src/cache/local_cache.rs +74 -0
  10. tiderace-0.1.0/crates/engine-core/src/cache/mod.rs +32 -0
  11. tiderace-0.1.0/crates/engine-core/src/cache/null_cache.rs +36 -0
  12. tiderace-0.1.0/crates/engine-core/src/cache/purity.rs +73 -0
  13. tiderace-0.1.0/crates/engine-core/src/cache/tiered_cache.rs +100 -0
  14. tiderace-0.1.0/crates/engine-core/src/collection/collector.rs +13 -0
  15. tiderace-0.1.0/crates/engine-core/src/collection/mod.rs +7 -0
  16. tiderace-0.1.0/crates/engine-core/src/collection/regex_collector.rs +216 -0
  17. tiderace-0.1.0/crates/engine-core/src/coverage/coverage_report.rs +39 -0
  18. tiderace-0.1.0/crates/engine-core/src/coverage/dep_graph.rs +158 -0
  19. tiderace-0.1.0/crates/engine-core/src/coverage/file_lines.rs +42 -0
  20. tiderace-0.1.0/crates/engine-core/src/coverage/mod.rs +17 -0
  21. tiderace-0.1.0/crates/engine-core/src/domain/mod.rs +20 -0
  22. tiderace-0.1.0/crates/engine-core/src/domain/node_id.rs +57 -0
  23. tiderace-0.1.0/crates/engine-core/src/domain/outcome.rs +53 -0
  24. tiderace-0.1.0/crates/engine-core/src/domain/run_report.rs +61 -0
  25. tiderace-0.1.0/crates/engine-core/src/domain/scope.rs +55 -0
  26. tiderace-0.1.0/crates/engine-core/src/domain/scope_path.rs +27 -0
  27. tiderace-0.1.0/crates/engine-core/src/domain/test_item.rs +19 -0
  28. tiderace-0.1.0/crates/engine-core/src/domain/test_result.rs +51 -0
  29. tiderace-0.1.0/crates/engine-core/src/domain/test_style.rs +25 -0
  30. tiderace-0.1.0/crates/engine-core/src/error.rs +26 -0
  31. tiderace-0.1.0/crates/engine-core/src/exec/fork_permit.rs +51 -0
  32. tiderace-0.1.0/crates/engine-core/src/exec/fork_plan.rs +124 -0
  33. tiderace-0.1.0/crates/engine-core/src/exec/fork_worker.rs +68 -0
  34. tiderace-0.1.0/crates/engine-core/src/exec/memory_governor.rs +164 -0
  35. tiderace-0.1.0/crates/engine-core/src/exec/mod.rs +38 -0
  36. tiderace-0.1.0/crates/engine-core/src/exec/shim_protocol.rs +147 -0
  37. tiderace-0.1.0/crates/engine-core/src/exec/subinterp_worker.rs +189 -0
  38. tiderace-0.1.0/crates/engine-core/src/exec/subprocess_worker.rs +155 -0
  39. tiderace-0.1.0/crates/engine-core/src/exec/transport.rs +326 -0
  40. tiderace-0.1.0/crates/engine-core/src/exec/watermark.rs +84 -0
  41. tiderace-0.1.0/crates/engine-core/src/exec/watermark_stack.rs +209 -0
  42. tiderace-0.1.0/crates/engine-core/src/exec/wellspring.rs +77 -0
  43. tiderace-0.1.0/crates/engine-core/src/exec/worker.rs +9 -0
  44. tiderace-0.1.0/crates/engine-core/src/exec/worker_caps.rs +35 -0
  45. tiderace-0.1.0/crates/engine-core/src/fixtures/closure_hash.rs +213 -0
  46. tiderace-0.1.0/crates/engine-core/src/fixtures/finalizer.rs +168 -0
  47. tiderace-0.1.0/crates/engine-core/src/fixtures/fixture.rs +109 -0
  48. tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_args.rs +48 -0
  49. tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_closure.rs +57 -0
  50. tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_error.rs +67 -0
  51. tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_graph.rs +611 -0
  52. tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_instance.rs +52 -0
  53. tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_plan.rs +120 -0
  54. tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_resolver.rs +42 -0
  55. tiderace-0.1.0/crates/engine-core/src/fixtures/layered_resolver.rs +542 -0
  56. tiderace-0.1.0/crates/engine-core/src/fixtures/mod.rs +44 -0
  57. tiderace-0.1.0/crates/engine-core/src/fixtures/override_table.rs +176 -0
  58. tiderace-0.1.0/crates/engine-core/src/fixtures/param_value.rs +45 -0
  59. tiderace-0.1.0/crates/engine-core/src/fixtures/scope_layer.rs +97 -0
  60. tiderace-0.1.0/crates/engine-core/src/fixtures/shim_handle.rs +24 -0
  61. tiderace-0.1.0/crates/engine-core/src/hooks/hook.rs +17 -0
  62. tiderace-0.1.0/crates/engine-core/src/hooks/hook_event.rs +18 -0
  63. tiderace-0.1.0/crates/engine-core/src/hooks/hook_host.rs +154 -0
  64. tiderace-0.1.0/crates/engine-core/src/hooks/mod.rs +19 -0
  65. tiderace-0.1.0/crates/engine-core/src/hooks/priority.rs +20 -0
  66. tiderace-0.1.0/crates/engine-core/src/impact/change.rs +39 -0
  67. tiderace-0.1.0/crates/engine-core/src/impact/impact_analyzer.rs +108 -0
  68. tiderace-0.1.0/crates/engine-core/src/impact/mod.rs +17 -0
  69. tiderace-0.1.0/crates/engine-core/src/impact/selection.rs +21 -0
  70. tiderace-0.1.0/crates/engine-core/src/lib.rs +27 -0
  71. tiderace-0.1.0/crates/engine-core/src/reporter/github_reporter.rs +65 -0
  72. tiderace-0.1.0/crates/engine-core/src/reporter/json_reporter.rs +80 -0
  73. tiderace-0.1.0/crates/engine-core/src/reporter/junit_xml_reporter.rs +101 -0
  74. tiderace-0.1.0/crates/engine-core/src/reporter/mod.rs +22 -0
  75. tiderace-0.1.0/crates/engine-core/src/reporter/reporter.rs +21 -0
  76. tiderace-0.1.0/crates/engine-core/src/reporter/sarif_reporter.rs +125 -0
  77. tiderace-0.1.0/crates/engine-core/src/reporter/terminal_reporter.rs +78 -0
  78. tiderace-0.1.0/crates/engine-core/src/scheduler/locality_scheduler.rs +186 -0
  79. tiderace-0.1.0/crates/engine-core/src/scheduler/mod.rs +24 -0
  80. tiderace-0.1.0/crates/engine-core/src/scheduler/round_robin_scheduler.rs +40 -0
  81. tiderace-0.1.0/crates/engine-core/src/scheduler/scheduled_test.rs +36 -0
  82. tiderace-0.1.0/crates/engine-core/src/scheduler/scheduler.rs +45 -0
  83. tiderace-0.1.0/crates/engine-core/src/scheduler/worker_batch.rs +45 -0
  84. tiderace-0.1.0/crates/engine-core/src/shim_locate.rs +52 -0
  85. tiderace-0.1.0/crates/engine-core/src/testing.rs +127 -0
  86. tiderace-0.1.0/crates/engine-core/tests/cache_impact_integration.rs +103 -0
  87. tiderace-0.1.0/crates/engine-core/tests/differential.rs +132 -0
  88. tiderace-0.1.0/crates/engine-core/tests/fixtures_acceptance.rs +527 -0
  89. tiderace-0.1.0/crates/engine-core/tests/fx_support/mod.rs +275 -0
  90. tiderace-0.1.0/crates/engine-core/tests/nofork_acceptance.rs +280 -0
  91. tiderace-0.1.0/crates/engine-core/tests/subinterp_acceptance.rs +161 -0
  92. tiderace-0.1.0/crates/engine-daemon/Cargo.toml +18 -0
  93. tiderace-0.1.0/crates/engine-daemon/src/engine_handler.rs +657 -0
  94. tiderace-0.1.0/crates/engine-daemon/src/fs_watcher.rs +109 -0
  95. tiderace-0.1.0/crates/engine-daemon/src/invalidator.rs +166 -0
  96. tiderace-0.1.0/crates/engine-daemon/src/lib.rs +37 -0
  97. tiderace-0.1.0/crates/engine-daemon/src/main.rs +278 -0
  98. tiderace-0.1.0/crates/engine-daemon/src/persist.rs +154 -0
  99. tiderace-0.1.0/crates/engine-daemon/src/pool.rs +334 -0
  100. tiderace-0.1.0/crates/engine-daemon/src/probe.rs +117 -0
  101. tiderace-0.1.0/crates/engine-daemon/src/rpc_method.rs +76 -0
  102. tiderace-0.1.0/crates/engine-daemon/src/rpc_server.rs +180 -0
  103. tiderace-0.1.0/crates/engine-daemon/src/session.rs +200 -0
  104. tiderace-0.1.0/crates/engine-daemon/src/socket.rs +19 -0
  105. tiderace-0.1.0/crates/engine-daemon/src/watch.rs +207 -0
  106. tiderace-0.1.0/crates/engine-daemon/tests/daemon_e2e.rs +150 -0
  107. tiderace-0.1.0/crates/tiderace-dist/Cargo.lock +435 -0
  108. tiderace-0.1.0/crates/tiderace-dist/Cargo.toml +11 -0
  109. tiderace-0.1.0/crates/tiderace-dist/README.md +7 -0
  110. tiderace-0.1.0/py-tiderace/tiderace/__init__.py +161 -0
  111. tiderace-0.1.0/py-tiderace/tiderace/_errors.py +18 -0
  112. tiderace-0.1.0/py-tiderace/tiderace/_resolve.py +79 -0
  113. tiderace-0.1.0/py-tiderace/tiderace/_shim/__init__.py +7 -0
  114. tiderace-0.1.0/py-tiderace/tiderace/_spec.py +44 -0
  115. tiderace-0.1.0/py-tiderace/tiderace/builtins/__init__.py +80 -0
  116. tiderace-0.1.0/py-tiderace/tiderace/builtins/_capture.py +97 -0
  117. tiderace-0.1.0/py-tiderace/tiderace/builtins/_monkeypatch.py +99 -0
  118. tiderace-0.1.0/py-tiderace/tiderace/builtins/_paths.py +20 -0
  119. tiderace-0.1.0/py-tiderace/tiderace/migrate.py +466 -0
  120. tiderace-0.1.0/pyproject.toml +35 -0
@@ -0,0 +1,27 @@
1
+ # Pure-Rust test engine workspace — the tiderace engine.
2
+ #
3
+ # Lives under engine/ (the earlier pytest-orchestrator crate that used to sit at the repo root has been
4
+ # removed). See planning/current/pure-rust-test-engine/ and design/01-architecture.md for the layout.
5
+ [workspace]
6
+ resolver = "2"
7
+ members = ["crates/engine-core", "crates/engine-cli", "crates/engine-daemon"]
8
+ # ② in-process/FFI backend (ADR-E013): a separate optional crate that links libpython (PyO3). Excluded
9
+ # from the default workspace build + the Windows CI job so the core engine stays dependency-free; built
10
+ # explicitly with `PYO3_PYTHON=<python-with-libpython> cargo build --manifest-path crates/engine-inproc/Cargo.toml`.
11
+ exclude = ["crates/engine-inproc", "crates/tiderace-dist"]
12
+
13
+ [workspace.package]
14
+ edition = "2021"
15
+ version = "0.0.1"
16
+ license = "MIT"
17
+
18
+ [workspace.dependencies]
19
+ serde = { version = "1", features = ["derive"] }
20
+ serde_json = "1"
21
+ thiserror = "2"
22
+ regex = "1"
23
+ # FS watching for the warm daemon (Phase 6, ADR-E007). Same version the legacy crate already vendors.
24
+ notify = "6.1.1"
25
+
26
+ [profile.release]
27
+ opt-level = 3
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: tiderace
3
+ Version: 0.1.0
4
+ Classifier: Development Status :: 3 - Alpha
5
+ Classifier: Programming Language :: Rust
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Topic :: Software Development :: Testing
8
+ Summary: A pure-Rust test engine for Python — its own runner, not a pytest wrapper.
9
+ Keywords: testing,pytest,rust,test-runner,impact-analysis
10
+ Author: John Aven
11
+ License: MIT
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
14
+ Project-URL: Homepage, https://snoodleboot-io.github.io/tiderace/
15
+ Project-URL: Repository, https://github.com/snoodleboot-io/tiderace
16
+
17
+ # tiderace
18
+
19
+ A pure-Rust test engine for Python — its own runner, not a pytest wrapper. Isolation without the fork
20
+ tax, and only runs what changed.
21
+
22
+ `pip install tiderace` ships the `tiderace` and `tiderace-daemon` binaries plus the native authoring
23
+ package. Docs: https://snoodleboot-io.github.io/tiderace/
24
+
@@ -0,0 +1,7 @@
1
+ # tiderace
2
+
3
+ A pure-Rust test engine for Python — its own runner, not a pytest wrapper. Isolation without the fork
4
+ tax, and only runs what changed.
5
+
6
+ `pip install tiderace` ships the `tiderace` and `tiderace-daemon` binaries plus the native authoring
7
+ package. Docs: https://snoodleboot-io.github.io/tiderace/
@@ -0,0 +1,12 @@
1
+ [package]
2
+ name = "engine-core"
3
+ description = "Pure-Rust Python test engine — core library (collection, domain, execution)."
4
+ edition.workspace = true
5
+ version.workspace = true
6
+ license.workspace = true
7
+
8
+ [dependencies]
9
+ serde = { workspace = true }
10
+ serde_json = { workspace = true }
11
+ thiserror = { workspace = true }
12
+ regex = { workspace = true }
@@ -0,0 +1,18 @@
1
+ use crate::cache::{CacheKey, CachedOutcome};
2
+
3
+ /// The cache seam (ADR-E005): a content-addressed store of test outcomes. Production wires a
4
+ /// [`TieredCache`](crate::cache::TieredCache) (local + optional remote); `--no-cache` / debugging
5
+ /// wires a [`NullCache`](crate::cache::NullCache).
6
+ ///
7
+ /// Implementors use interior mutability (`&self` on `put`) so a single cache can be shared across the
8
+ /// worker pool without a `&mut` bottleneck. The orchestrator's preference order is **cache hit →
9
+ /// impact-skip → run** (ADR-E004): consult [`get`](Self::get) first, fall back to impact selection,
10
+ /// then execute and [`put`](Self::put) the fresh outcome.
11
+ pub trait Cache: Send + Sync {
12
+ /// The cached outcome for `key`, or `None` on a miss.
13
+ fn get(&self, key: &CacheKey) -> Option<CachedOutcome>;
14
+
15
+ /// Store `outcome` under `key`. Callers must only store **pure** outcomes
16
+ /// (`Purity::is_cacheable`) — impure tests are never silently cached (ADR-E004 soundness).
17
+ fn put(&self, key: &CacheKey, outcome: CachedOutcome);
18
+ }
@@ -0,0 +1,187 @@
1
+ use std::collections::BTreeMap;
2
+ use std::path::{Path, PathBuf};
3
+
4
+ use crate::fixtures::{ClosureHash, ClosureHasher};
5
+
6
+ /// The content-addressed cache key for one test outcome (ADR-E004): a hash over the test's
7
+ /// *transitive input closure*, so an outcome is a pure function of its inputs and shareable across
8
+ /// machines/CI. Built by [`CacheKeyBuilder`], which composes the ADR's terms:
9
+ ///
10
+ /// ```text
11
+ /// key = H( test_identity + executed_source_closure (from coverage, E006)
12
+ /// + fixture_closure (ClosureHash) + declared_env
13
+ /// + engine_version + python_version + platform )
14
+ /// ```
15
+ ///
16
+ /// Reuses the engine's existing deterministic [`ClosureHasher`] (no new crypto dependency — see its
17
+ /// module note; swap-in point is documented there if collision-resistance is later required).
18
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19
+ pub struct CacheKey(ClosureHash);
20
+
21
+ impl CacheKey {
22
+ /// Wrap a precomputed digest.
23
+ pub fn from_hash(h: ClosureHash) -> Self {
24
+ Self(h)
25
+ }
26
+
27
+ /// The underlying digest.
28
+ pub fn hash(&self) -> &ClosureHash {
29
+ &self.0
30
+ }
31
+
32
+ /// Lowercase-hex rendering (the index/diagnostics form).
33
+ pub fn to_hex(&self) -> String {
34
+ self.0.to_hex()
35
+ }
36
+ }
37
+
38
+ /// Composes a [`CacheKey`] deterministically, independent of call order: environment terms are fed at
39
+ /// construction, the executed-source closure and declared env accumulate into sorted maps, and
40
+ /// [`finish`](Self::finish) feeds everything in a fixed order. The **executed-source closure comes
41
+ /// from coverage** (ADR-E006), not a static guess — that is what makes the cache sound.
42
+ #[derive(Debug, Clone)]
43
+ pub struct CacheKeyBuilder {
44
+ node_id: String,
45
+ fixture_closure: Option<ClosureHash>,
46
+ /// relative source path -> its content hash (sorted for determinism).
47
+ sources: BTreeMap<PathBuf, [u8; 32]>,
48
+ /// declared env var -> value (sorted for determinism).
49
+ env: BTreeMap<String, String>,
50
+ engine_version: String,
51
+ python_version: String,
52
+ platform: String,
53
+ }
54
+
55
+ impl CacheKeyBuilder {
56
+ /// Start a key for `node_id`, pinning the environment terms (so a cache built under one
57
+ /// engine/python/platform never poisons another — ADR-E004 invalidation requirement).
58
+ pub fn new(
59
+ node_id: impl Into<String>,
60
+ engine_version: impl Into<String>,
61
+ python_version: impl Into<String>,
62
+ platform: impl Into<String>,
63
+ ) -> Self {
64
+ Self {
65
+ node_id: node_id.into(),
66
+ fixture_closure: None,
67
+ sources: BTreeMap::new(),
68
+ env: BTreeMap::new(),
69
+ engine_version: engine_version.into(),
70
+ python_version: python_version.into(),
71
+ platform: platform.into(),
72
+ }
73
+ }
74
+
75
+ /// Bind the fixture-closure term (the existing [`ClosureHash`], W14).
76
+ pub fn fixture_closure(&mut self, h: ClosureHash) -> &mut Self {
77
+ self.fixture_closure = Some(h);
78
+ self
79
+ }
80
+
81
+ /// Add one executed source file and its content hash (call once per touched file; order-free).
82
+ pub fn executed_source(
83
+ &mut self,
84
+ path: impl Into<PathBuf>,
85
+ content_hash: [u8; 32],
86
+ ) -> &mut Self {
87
+ self.sources.insert(path.into(), content_hash);
88
+ self
89
+ }
90
+
91
+ /// Declare an env var the test is allowed to read (part of the closure; order-free).
92
+ pub fn declared_env(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
93
+ self.env.insert(key.into(), value.into());
94
+ self
95
+ }
96
+
97
+ /// Finalize into the content-addressed [`CacheKey`].
98
+ pub fn finish(&self) -> CacheKey {
99
+ let mut h = ClosureHasher::new();
100
+ // Domain-separate each section with a tag so a value can't migrate between fields.
101
+ h.feed_str("engine").feed_str(&self.engine_version);
102
+ h.feed_str("python").feed_str(&self.python_version);
103
+ h.feed_str("platform").feed_str(&self.platform);
104
+ h.feed_str("node").feed_str(&self.node_id);
105
+ h.feed_str("fixtures").feed(
106
+ self.fixture_closure
107
+ .map(|c| *c.as_bytes())
108
+ .unwrap_or([0u8; 32])
109
+ .as_slice(),
110
+ );
111
+ h.feed_str("sources");
112
+ for (path, digest) in &self.sources {
113
+ h.feed(path_bytes(path)).feed(digest);
114
+ }
115
+ h.feed_str("env");
116
+ for (key, value) in &self.env {
117
+ h.feed_str(key).feed_str(value);
118
+ }
119
+ CacheKey::from_hash(h.finish())
120
+ }
121
+ }
122
+
123
+ #[cfg(unix)]
124
+ fn path_bytes(p: &Path) -> &[u8] {
125
+ use std::os::unix::ffi::OsStrExt;
126
+ p.as_os_str().as_bytes()
127
+ }
128
+
129
+ #[cfg(not(unix))]
130
+ fn path_bytes(p: &Path) -> &[u8] {
131
+ // Non-unix: lossy is acceptable — the digest just needs to be stable per-platform, and the
132
+ // platform term already partitions keys across OSes.
133
+ p.to_str().unwrap_or("").as_bytes()
134
+ }
135
+
136
+ #[cfg(test)]
137
+ mod tests {
138
+ use super::*;
139
+
140
+ fn base() -> CacheKeyBuilder {
141
+ CacheKeyBuilder::new("t.py::a", "0.5.0", "3.12.3", "linux-x86_64")
142
+ }
143
+
144
+ #[test]
145
+ fn identical_closures_yield_equal_keys() {
146
+ let a = base().executed_source("src.py", [7u8; 32]).finish();
147
+ let b = base().executed_source("src.py", [7u8; 32]).finish();
148
+ assert_eq!(a, b);
149
+ }
150
+
151
+ #[test]
152
+ fn source_content_change_changes_key() {
153
+ let a = base().executed_source("src.py", [7u8; 32]).finish();
154
+ let b = base().executed_source("src.py", [8u8; 32]).finish();
155
+ assert_ne!(a, b, "a changed source must miss the cache");
156
+ }
157
+
158
+ #[test]
159
+ fn env_terms_partition_keys() {
160
+ let linux = CacheKeyBuilder::new("t.py::a", "0.5.0", "3.12.3", "linux").finish();
161
+ let mac = CacheKeyBuilder::new("t.py::a", "0.5.0", "3.12.3", "macos").finish();
162
+ assert_ne!(
163
+ linux, mac,
164
+ "platform must partition the cache (no cross-env poisoning)"
165
+ );
166
+ }
167
+
168
+ #[test]
169
+ fn source_order_does_not_matter() {
170
+ let mut a = base();
171
+ a.executed_source("a.py", [1u8; 32])
172
+ .executed_source("b.py", [2u8; 32]);
173
+ let mut b = base();
174
+ b.executed_source("b.py", [2u8; 32])
175
+ .executed_source("a.py", [1u8; 32]);
176
+ assert_eq!(a.finish(), b.finish());
177
+ }
178
+
179
+ #[test]
180
+ fn fixture_closure_term_participates() {
181
+ let a = base().finish();
182
+ let b = base()
183
+ .fixture_closure(ClosureHash::from_bytes([9u8; 32]))
184
+ .finish();
185
+ assert_ne!(a, b);
186
+ }
187
+ }
@@ -0,0 +1,33 @@
1
+ use serde::{Deserialize, Serialize};
2
+
3
+ use crate::domain::Outcome;
4
+
5
+ /// The stored artifact for one content-addressed test result (ADR-E004): the outcome plus its
6
+ /// captured diagnostics, enough to reconstruct a [`crate::domain::TestResult`] on a cache hit without
7
+ /// re-running the test. (`duration_ms` is intentionally **not** part of the key's identity — it is
8
+ /// recorded for reporting but a hit reports it as cached/zero.)
9
+ ///
10
+ /// `Serialize`/`Deserialize` so a shareable tier ([`DirCache`](crate::cache::DirCache)) can persist it
11
+ /// across machines/CI.
12
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13
+ pub struct CachedOutcome {
14
+ outcome: Outcome,
15
+ detail: String,
16
+ }
17
+
18
+ impl CachedOutcome {
19
+ pub fn new(outcome: Outcome, detail: impl Into<String>) -> Self {
20
+ Self {
21
+ outcome,
22
+ detail: detail.into(),
23
+ }
24
+ }
25
+
26
+ pub fn outcome(&self) -> Outcome {
27
+ self.outcome
28
+ }
29
+
30
+ pub fn detail(&self) -> &str {
31
+ &self.detail
32
+ }
33
+ }
@@ -0,0 +1,187 @@
1
+ use std::path::{Path, PathBuf};
2
+ use std::sync::atomic::{AtomicU64, Ordering};
3
+
4
+ use crate::cache::{Cache, CacheKey, CachedOutcome};
5
+
6
+ /// A **directory-backed** cache tier — the shareable remote store (ADR-E004). Each entry is a JSON file
7
+ /// named by the content hash (`<hex>.json`) in `root`, so the store is just a directory: point it at a
8
+ /// CI cache path (`actions/cache`), a shared mount / NFS, or an artifact dir and a green result computed
9
+ /// on one machine is a free hit on any other with the same inputs.
10
+ ///
11
+ /// It sits behind the [`Cache`] seam exactly like [`LocalCache`](crate::cache::LocalCache), so
12
+ /// [`TieredCache`](crate::cache::TieredCache)`::with_remote(local, DirCache::new(dir))` gives the
13
+ /// local→remote→backfill flow with zero orchestrator changes. A future HTTP/object-store client is a
14
+ /// drop-in behind the same trait.
15
+ ///
16
+ /// **Best-effort by design**: a cache is an optimization, so every I/O error is a silent miss / no-op —
17
+ /// `get`/`put` never panic and never fail a run. Writes are atomic (temp file + rename) so a concurrent
18
+ /// reader (or a killed writer) never observes a half-written entry.
19
+ #[derive(Debug, Clone)]
20
+ pub struct DirCache {
21
+ root: PathBuf,
22
+ }
23
+
24
+ /// Process-global counter making each in-flight temp file name unique (multiple threads may write the
25
+ /// same key concurrently; they must not collide on the temp path before the atomic rename).
26
+ static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
27
+
28
+ impl DirCache {
29
+ /// Open (and best-effort create) the cache directory at `root`.
30
+ pub fn new(root: impl Into<PathBuf>) -> Self {
31
+ let root = root.into();
32
+ let _ = std::fs::create_dir_all(&root); // get/put tolerate a missing dir anyway
33
+ Self { root }
34
+ }
35
+
36
+ /// The on-disk path for a key's entry.
37
+ fn entry_path(&self, key: &CacheKey) -> PathBuf {
38
+ self.root.join(format!("{}.json", key.to_hex()))
39
+ }
40
+
41
+ /// Number of stored entries (`*.json` files) — diagnostics/tests.
42
+ pub fn len(&self) -> usize {
43
+ std::fs::read_dir(&self.root)
44
+ .map(|rd| {
45
+ rd.filter_map(|e| e.ok())
46
+ .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
47
+ .count()
48
+ })
49
+ .unwrap_or(0)
50
+ }
51
+
52
+ /// Whether the store has no entries.
53
+ pub fn is_empty(&self) -> bool {
54
+ self.len() == 0
55
+ }
56
+
57
+ /// The directory this cache reads/writes (for diagnostics).
58
+ pub fn root(&self) -> &Path {
59
+ &self.root
60
+ }
61
+ }
62
+
63
+ impl Cache for DirCache {
64
+ fn get(&self, key: &CacheKey) -> Option<CachedOutcome> {
65
+ // A missing file, an unreadable one, or a corrupt/foreign entry all read as a plain miss.
66
+ let bytes = std::fs::read(self.entry_path(key)).ok()?;
67
+ serde_json::from_slice(&bytes).ok()
68
+ }
69
+
70
+ fn put(&self, key: &CacheKey, outcome: CachedOutcome) {
71
+ let Ok(json) = serde_json::to_vec(&outcome) else {
72
+ return; // unserializable ⇒ just don't cache
73
+ };
74
+ // Write to a unique temp file in the same directory, then atomically rename into place, so a
75
+ // reader never sees a partial entry and two writers of the same key can't corrupt each other.
76
+ let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
77
+ let tmp = self.root.join(format!(
78
+ ".{}.{}.{seq}.tmp",
79
+ key.to_hex(),
80
+ std::process::id()
81
+ ));
82
+ if std::fs::write(&tmp, &json).is_ok() {
83
+ if std::fs::rename(&tmp, self.entry_path(key)).is_err() {
84
+ let _ = std::fs::remove_file(&tmp); // rename failed (e.g. dir vanished) → clean up
85
+ }
86
+ } else {
87
+ let _ = std::fs::remove_file(&tmp);
88
+ }
89
+ }
90
+ }
91
+
92
+ #[cfg(test)]
93
+ mod tests {
94
+ use super::*;
95
+ use crate::cache::{CacheKeyBuilder, LocalCache, TieredCache};
96
+ use crate::domain::Outcome;
97
+
98
+ fn key(node: &str) -> CacheKey {
99
+ CacheKeyBuilder::new(node, "0.5.0", "3.12", "linux").finish()
100
+ }
101
+
102
+ /// A unique temp directory per test (removed on drop).
103
+ struct TempDir(PathBuf);
104
+ impl TempDir {
105
+ fn new(tag: &str) -> Self {
106
+ static SEQ: AtomicU64 = AtomicU64::new(0);
107
+ let p = std::env::temp_dir().join(format!(
108
+ "tiderace_dircache_{tag}_{}_{}",
109
+ std::process::id(),
110
+ SEQ.fetch_add(1, Ordering::Relaxed)
111
+ ));
112
+ let _ = std::fs::remove_dir_all(&p);
113
+ Self(p)
114
+ }
115
+ }
116
+ impl Drop for TempDir {
117
+ fn drop(&mut self) {
118
+ let _ = std::fs::remove_dir_all(&self.0);
119
+ }
120
+ }
121
+
122
+ #[test]
123
+ fn miss_then_hit_on_disk() {
124
+ let dir = TempDir::new("hit");
125
+ let c = DirCache::new(&dir.0);
126
+ let k = key("t.py::a");
127
+ assert!(c.get(&k).is_none());
128
+ c.put(&k, CachedOutcome::new(Outcome::Passed, "ok"));
129
+ let hit = c.get(&k).expect("hit after put");
130
+ assert_eq!(hit.outcome(), Outcome::Passed);
131
+ assert_eq!(hit.detail(), "ok");
132
+ assert_eq!(c.len(), 1);
133
+ }
134
+
135
+ #[test]
136
+ fn persists_across_instances_same_dir() {
137
+ // The "share across machines" property: machine A writes; a fresh cache over the same directory
138
+ // (machine B, having pulled the CI artifact) reads a hit.
139
+ let dir = TempDir::new("share");
140
+ let k = key("t.py::shared");
141
+ DirCache::new(&dir.0).put(&k, CachedOutcome::new(Outcome::Failed, "boom"));
142
+
143
+ let machine_b = DirCache::new(&dir.0); // separate instance, same directory
144
+ let hit = machine_b.get(&k).expect("cross-instance hit");
145
+ assert_eq!(hit.outcome(), Outcome::Failed);
146
+ assert_eq!(hit.detail(), "boom");
147
+ }
148
+
149
+ #[test]
150
+ fn distinct_keys_do_not_collide() {
151
+ let dir = TempDir::new("distinct");
152
+ let c = DirCache::new(&dir.0);
153
+ c.put(&key("t.py::a"), CachedOutcome::new(Outcome::Passed, ""));
154
+ c.put(&key("t.py::b"), CachedOutcome::new(Outcome::Failed, "x"));
155
+ assert_eq!(c.get(&key("t.py::a")).unwrap().outcome(), Outcome::Passed);
156
+ assert_eq!(c.get(&key("t.py::b")).unwrap().outcome(), Outcome::Failed);
157
+ assert_eq!(c.len(), 2);
158
+ }
159
+
160
+ #[test]
161
+ fn corrupt_or_foreign_entry_is_a_miss_not_a_panic() {
162
+ let dir = TempDir::new("corrupt");
163
+ let c = DirCache::new(&dir.0);
164
+ let k = key("t.py::a");
165
+ std::fs::write(c.entry_path(&k), b"{ not valid json").unwrap();
166
+ assert!(c.get(&k).is_none(), "corrupt entry reads as a miss");
167
+ }
168
+
169
+ #[test]
170
+ fn get_on_missing_directory_is_a_miss() {
171
+ let c = DirCache::new("/nonexistent/tiderace/cache/dir");
172
+ assert!(c.get(&key("t.py::a")).is_none());
173
+ // put must not panic even if the directory can't be created/written.
174
+ c.put(&key("t.py::a"), CachedOutcome::new(Outcome::Passed, ""));
175
+ }
176
+
177
+ #[test]
178
+ fn tiered_with_dir_remote_backfills_local() {
179
+ // CI populated the shared dir; a fresh machine's local tier is cold → remote hit → backfill.
180
+ let dir = TempDir::new("tiered");
181
+ let k = key("t.py::a");
182
+ DirCache::new(&dir.0).put(&k, CachedOutcome::new(Outcome::Passed, "")); // "CI" wrote it
183
+
184
+ let tiered = TieredCache::with_remote(LocalCache::new(), DirCache::new(&dir.0));
185
+ assert_eq!(tiered.get(&k).unwrap().outcome(), Outcome::Passed); // local miss → remote hit
186
+ }
187
+ }
@@ -0,0 +1,74 @@
1
+ use std::collections::HashMap;
2
+ use std::sync::Mutex;
3
+
4
+ use crate::cache::{Cache, CacheKey, CachedOutcome};
5
+
6
+ /// The local cache tier: an in-process content store keyed by [`CacheKey`]. Backed by a `Mutex`-guarded
7
+ /// map so it satisfies the `Cache: Send + Sync` seam and can be shared across the worker pool.
8
+ ///
9
+ /// ADR-E004 specifies a content store + SQLite index on disk for cross-run persistence; this in-memory
10
+ /// store is the faithful `Cache` impl for the warm/inner-loop path and the unit substrate. SQLite-backed
11
+ /// persistence is a drop-in behind the same trait (the `persist`/`load` follow-on).
12
+ #[derive(Debug, Default)]
13
+ pub struct LocalCache {
14
+ entries: Mutex<HashMap<CacheKey, CachedOutcome>>,
15
+ }
16
+
17
+ impl LocalCache {
18
+ pub fn new() -> Self {
19
+ Self::default()
20
+ }
21
+
22
+ /// The number of cached entries (diagnostics/tests).
23
+ pub fn len(&self) -> usize {
24
+ self.entries.lock().expect("cache mutex").len()
25
+ }
26
+
27
+ /// Whether the cache is empty.
28
+ pub fn is_empty(&self) -> bool {
29
+ self.entries.lock().expect("cache mutex").is_empty()
30
+ }
31
+ }
32
+
33
+ impl Cache for LocalCache {
34
+ fn get(&self, key: &CacheKey) -> Option<CachedOutcome> {
35
+ self.entries.lock().expect("cache mutex").get(key).cloned()
36
+ }
37
+
38
+ fn put(&self, key: &CacheKey, outcome: CachedOutcome) {
39
+ self.entries
40
+ .lock()
41
+ .expect("cache mutex")
42
+ .insert(*key, outcome);
43
+ }
44
+ }
45
+
46
+ #[cfg(test)]
47
+ mod tests {
48
+ use super::*;
49
+ use crate::cache::CacheKeyBuilder;
50
+ use crate::domain::Outcome;
51
+
52
+ fn key(node: &str) -> CacheKey {
53
+ CacheKeyBuilder::new(node, "0.5.0", "3.12", "linux").finish()
54
+ }
55
+
56
+ #[test]
57
+ fn miss_then_hit() {
58
+ let c = LocalCache::new();
59
+ let k = key("t.py::a");
60
+ assert!(c.get(&k).is_none());
61
+ c.put(&k, CachedOutcome::new(Outcome::Passed, ""));
62
+ assert_eq!(c.get(&k).unwrap().outcome(), Outcome::Passed);
63
+ assert_eq!(c.len(), 1);
64
+ }
65
+
66
+ #[test]
67
+ fn distinct_keys_do_not_collide() {
68
+ let c = LocalCache::new();
69
+ c.put(&key("t.py::a"), CachedOutcome::new(Outcome::Passed, ""));
70
+ c.put(&key("t.py::b"), CachedOutcome::new(Outcome::Failed, "boom"));
71
+ assert_eq!(c.get(&key("t.py::a")).unwrap().outcome(), Outcome::Passed);
72
+ assert_eq!(c.get(&key("t.py::b")).unwrap().outcome(), Outcome::Failed);
73
+ }
74
+ }
@@ -0,0 +1,32 @@
1
+ //! Content-addressed result cache (Phase 5, ADR-E004) — a build-system-for-tests.
2
+ //!
3
+ //! The fastest test is the one never run. Each outcome is modelled as a pure function of its inputs
4
+ //! and stored by content hash, so warm/CI runs approach O(changed tests) and a green result is
5
+ //! shareable across machines. The [`CacheKey`] hashes the test's transitive input closure — crucially
6
+ //! the **executed-source closure from coverage** ([`crate::coverage`]), not a static guess, which is
7
+ //! what makes the cache *sound*. Impure tests are never silently cached ([`Purity`]).
8
+ //!
9
+ //! Seams (ADR-E005): the [`Cache`] trait with [`LocalCache`] + [`TieredCache`] for CI sharing (backed
10
+ //! by a shareable [`DirCache`] remote tier) and [`NullCache`] for debugging. One type per file.
11
+ //!
12
+ //! Orchestrator preference order (ADR-E004): **cache hit → impact-skip → run**.
13
+
14
+ #[allow(clippy::module_inception)]
15
+ // file name = snake_case of the `Cache` trait (project convention)
16
+ mod cache;
17
+ mod cache_key;
18
+ mod cached_outcome;
19
+ mod dir_cache;
20
+ mod local_cache;
21
+ mod null_cache;
22
+ mod purity;
23
+ mod tiered_cache;
24
+
25
+ pub use cache::Cache;
26
+ pub use cache_key::{CacheKey, CacheKeyBuilder};
27
+ pub use cached_outcome::CachedOutcome;
28
+ pub use dir_cache::DirCache;
29
+ pub use local_cache::LocalCache;
30
+ pub use null_cache::NullCache;
31
+ pub use purity::{NoSandbox, Purity, SandboxHooks};
32
+ pub use tiered_cache::TieredCache;
@@ -0,0 +1,36 @@
1
+ use crate::cache::{Cache, CacheKey, CachedOutcome};
2
+
3
+ /// A cache that stores nothing and always misses (ADR-E005). Wired by `--no-cache` and used in
4
+ /// debugging / differential runs where every test must actually execute. Lets the orchestrator depend
5
+ /// on the [`Cache`] seam unconditionally instead of branching on an `Option<Cache>`.
6
+ #[derive(Debug, Default, Clone, Copy)]
7
+ pub struct NullCache;
8
+
9
+ impl NullCache {
10
+ pub fn new() -> Self {
11
+ Self
12
+ }
13
+ }
14
+
15
+ impl Cache for NullCache {
16
+ fn get(&self, _key: &CacheKey) -> Option<CachedOutcome> {
17
+ None
18
+ }
19
+
20
+ fn put(&self, _key: &CacheKey, _outcome: CachedOutcome) {}
21
+ }
22
+
23
+ #[cfg(test)]
24
+ mod tests {
25
+ use super::*;
26
+ use crate::cache::CacheKeyBuilder;
27
+ use crate::domain::Outcome;
28
+
29
+ #[test]
30
+ fn never_stores() {
31
+ let c = NullCache::new();
32
+ let k = CacheKeyBuilder::new("t.py::a", "0.5.0", "3.12", "linux").finish();
33
+ c.put(&k, CachedOutcome::new(Outcome::Passed, ""));
34
+ assert!(c.get(&k).is_none());
35
+ }
36
+ }