Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5241b8aa98 | ||
|
|
a0328066bc |
38 changed files with 68 additions and 3316 deletions
2
.github/workflows/cultmesh-portability.yml
vendored
2
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -158,7 +158,7 @@ jobs:
|
|||
# (only the interop example built, inside the C# store tests). The target directory is under RUNNER_TEMP, away from the checkout.
|
||||
- name: Verify the CultCache document owner with the Rust cache
|
||||
shell: bash
|
||||
run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --locked --manifest-path packages/cultcache-rs/Cargo.toml
|
||||
run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --manifest-path packages/cultcache-rs/Cargo.toml
|
||||
|
||||
# tests/GameCult.Caching.Tests, the C# reference cache's own suite (about 1,260 tests, under a minute), ran in no
|
||||
# forge workflow; only the Mesh tests above exercised the reference.
|
||||
|
|
|
|||
4
.github/workflows/cultnet-interop.yml
vendored
4
.github/workflows/cultnet-interop.yml
vendored
|
|
@ -135,10 +135,10 @@ jobs:
|
|||
python -m unittest discover -s packages/cultmesh-py/tests
|
||||
|
||||
- name: Verify Rust RUDP reliability
|
||||
run: cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml rudp
|
||||
run: cargo test --manifest-path packages/cultnet-rs/Cargo.toml rudp
|
||||
|
||||
- name: Verify Rust CultMesh runtime-local surface
|
||||
run: cargo test --locked --manifest-path packages/cultmesh-rs/Cargo.toml
|
||||
run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml
|
||||
|
||||
- name: Verify C# RUDP reliability
|
||||
run: dotnet test tests/GameCult.Networking.Tests/GameCult.Networking.Tests.csproj --filter "FullyQualifiedName~Rudp"
|
||||
|
|
|
|||
|
|
@ -212,8 +212,8 @@ identity-owned C# session after provider route replacement.
|
|||
Rust package tests:
|
||||
|
||||
```powershell
|
||||
cargo test --locked --manifest-path packages/cultcache-rs/Cargo.toml
|
||||
cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml
|
||||
cargo test --manifest-path packages/cultcache-rs/Cargo.toml
|
||||
cargo test --manifest-path packages/cultnet-rs/Cargo.toml
|
||||
```
|
||||
|
||||
Kotlin package build:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
CultLib measures its C# test suites with Stryker.NET, pinned as a repo-local
|
||||
tool in `dotnet-tools.json`. Coverage is added project by project; a project
|
||||
with no `stryker-config.json` next to its test project has not been scoped
|
||||
yet (see "Scope and follow-ups" below for what's outstanding).
|
||||
yet (see "Follow-ups" below for what's outstanding).
|
||||
|
||||
## Running it
|
||||
|
||||
|
|
@ -126,17 +126,14 @@ project as precedent for the `vstest` caveat itself - that caveat is
|
|||
`mtp`-runner-specific and documented above for `static readonly` field
|
||||
initializers, and GameCult.Geometry has never actually hit it.
|
||||
|
||||
## Scope and follow-ups
|
||||
## Follow-ups
|
||||
|
||||
CultMath.Tests, GameCult.Geometry.Tests and GameCult.Caching.Tests are the
|
||||
projects currently scoped. GameCult.Geometry's `stryker-config.json` has no
|
||||
`test-runner` entry: the project is NUnit 4 with `NUnit3TestAdapter` and
|
||||
CultMath.Tests and GameCult.Geometry.Tests are the only projects currently
|
||||
scoped. GameCult.Geometry's `stryker-config.json` has no `test-runner`
|
||||
entry: the project is NUnit 4 with `NUnit3TestAdapter` and
|
||||
`Microsoft.NET.Test.Sdk`, so Stryker's default `vstest` runner applies
|
||||
directly (no `mtp` workaround needed, unlike CultMath.Tests's xunit.v3
|
||||
suite). GameCult.Caching.Tests is the same shape (NUnit, default `vstest`)
|
||||
and its `stryker-config.json` is driven the same way, by the repo-local
|
||||
`dotnet-stryker` tool: `dotnet tool restore` once, then `dotnet tool run
|
||||
dotnet-stryker` from `tests/GameCult.Caching.Tests`.
|
||||
suite).
|
||||
|
||||
GameCult.Geometry has only ever been run `--since:`-scoped to the cut that
|
||||
added surface nets; a full-project baseline (running mutation against all of
|
||||
|
|
@ -154,83 +151,7 @@ file, anything under `Fixtures/`) loses `--since` line-level scoping for that
|
|||
run; a future cut whose diff is C#-only, against a base commit that already
|
||||
has `stryker-config.json`, should scope correctly.
|
||||
|
||||
### GameCult.Caching.Tests
|
||||
|
||||
`tests/GameCult.Caching.Tests/stryker-config.json` mutates all of
|
||||
`src/GameCult.Caching` (the project is `GameCult.Caching.csproj`; Standard
|
||||
mutation level, `break` 0, the same thresholds as Geometry). Caching was
|
||||
scoped before Networking because its suite is deterministic and quick (1415
|
||||
tests, about 40 s) over about 7.8k lines of source, where GameCult.Networking
|
||||
is about 20k lines and its tests lean on socket timing, so a mutant timing out
|
||||
there says little about the test. A full Caching run is about 2,900 candidate
|
||||
mutants (3,602 created, minus 730 that fail to compile) at 1.25 to 1.6 s
|
||||
each, so about 1 to 1.3 hours; run it detached and read the JSON report,
|
||||
never as one attached session.
|
||||
|
||||
The first measurement was a narrow pass over the three files that carry the
|
||||
store-replace and element-id machinery, from `tests/GameCult.Caching.Tests`:
|
||||
|
||||
```bash
|
||||
dotnet tool run dotnet-stryker \
|
||||
-m '**/src/GameCult.Caching/StoreReplace.cs' \
|
||||
-m '**/src/GameCult.Caching/CultStoreLinks.cs' \
|
||||
-m '**/src/GameCult.Caching/CultElementIds.cs'
|
||||
```
|
||||
|
||||
A clean single run of that command tests 243 to 265 mutants (the count moves with the revision) and scored 85.28% on
|
||||
568a60fa, before any test of this scope was added; 86.79% on d38b5728, with
|
||||
the first batch of tests; and 87.17% on 76d9e7d1, with the throwing-enumerator
|
||||
case, in about 10 minutes. The first measurement of it overlapped two Stryker
|
||||
runs, so its numbers (and the 114 survivors it showed for `CultElementIds.cs`,
|
||||
where tests that kill a mutant by hand reported `Survived` or `Timeout`) are
|
||||
not measurements and are not quoted here. Never run two Stryker runs against
|
||||
one checkout at once, and check `ps` before starting one. A clean single run
|
||||
on `CultElementIds.cs` alone, after the tests below, tested 228 mutants: 208
|
||||
`Killed`, 8 `Timeout`, 12 `Survived`, a score of 94.74%. The 12th survivor
|
||||
was 321, which the throwing-enumerator case in `ListsOfValuesHoldNoIds` now
|
||||
kills; the other 11 are equivalent mutants. With that case a clean single run
|
||||
on `CultElementIds.cs` is 209 `Killed`, 8 `Timeout`, 11 `Survived`, 95.18%.
|
||||
|
||||
Real gaps that run exposed, each now pinned by `ElementIdWalkTests` (and each
|
||||
failing when its mutant is applied by hand): the order of the id path (the
|
||||
codec refuses at the first type MessagePack cannot allow, so order is what an
|
||||
error names first; `Append` to `Prepend` at 163, 167 and 180); the
|
||||
union-cycle guard in `ConcreteTypes` (248; the mutant is a stack overflow, so
|
||||
the red is a crashed test host: a depth guard would need production code in
|
||||
`ConcreteTypes`, since the recursion is private and a stack overflow cannot be
|
||||
caught by the test, so the regression test is left to crash loudly); a list of values reporting that it holds an
|
||||
id (`Holds`, 322); `[Key]` read through an override (`KeyOf`, 583); and the
|
||||
`IsWrittenWhole` and `IsKeyed` predicates (629 to 631 and 636: formatter,
|
||||
`[MessagePackObject]` and `KeyAsPropertyName` inherited from a base, and the
|
||||
`[DataContract]` without `[MessagePackObject]` and `||` arms).
|
||||
|
||||
Equivalent survivors in `CultElementIds.cs` (11), each hand-checked against the full
|
||||
suite; none can be told apart by any test:
|
||||
|
||||
- 142, 251, 636 (second arm), 506 (`IsDefined(UnionAttribute, false)` to
|
||||
`true`): `UnionAttribute` is `Inherited = false`, so its `inherit` argument
|
||||
changes nothing. Likewise 630's `DataContractAttribute` `inherit` argument
|
||||
(`DataContractAttribute` is `Inherited = false`).
|
||||
- 264, 542: `group.First()` to `FirstOrDefault()` over `GroupBy` groups, which
|
||||
are never empty.
|
||||
- 491: `new StringBuilder(bytes.Length * 2)` to `/ 2` changes only the initial
|
||||
capacity.
|
||||
- 578: removing the `Nullable.GetUnderlyingType` unwrap in `WalksAsOwner`;
|
||||
`Classify` unwraps nullables itself, so the unwrap is dead. A cut
|
||||
candidate, not a test gap.
|
||||
- 645, 657: `Append(type)` to `Prepend(type)` in `ElementTypeOf` and
|
||||
`MapTypes`; `type` matches only when it is itself `IEnumerable<>`,
|
||||
`IDictionary<,>` or `IReadOnlyDictionary<,>`, and none of those has a second
|
||||
matching interface, so the position of `type` never decides the answer.
|
||||
|
||||
The Windows-only P/Invoke group is exercised only on Windows: its mutants are
|
||||
`NoCoverage` on Linux and are a target for a Windows-leg mutation run, not a
|
||||
Linux gap or a finding. `StoreReplace.cs` line 45 is a different case: its
|
||||
negated `IsOSPlatform(Windows)` mutant is `Survived` on Linux, because the
|
||||
negated branch calls `ReplaceWindows`, whose `DllNotFound` fallback gives the
|
||||
same result.
|
||||
|
||||
`GameCult.Mesh` (+ `.Quic`, `.Quic.Native`),
|
||||
`GameCult.Caching`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`),
|
||||
`GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes
|
||||
(`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each still need their own
|
||||
`stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ this page.
|
|||
|
||||
| | CultCache | CultMath | Geometry | CultNet | CultMesh |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| **C#** | [first call, tested](#c-first-call); [update, tested](#c-update-with-retry) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
|
||||
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
|
||||
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run |
|
||||
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet |
|
||||
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet |
|
||||
|
|
@ -70,37 +70,6 @@ public static class FirstCall
|
|||
|
||||
`FirstCall.Run` is the call; a second test in that file fails if the block above stops matching it. More: `src/GameCult.Caching/README.md`.
|
||||
|
||||
## C# update with retry
|
||||
|
||||
Change a record you may not be the only one changing. `Update` reads the record, hands your function a private
|
||||
copy, and commits the result only if the record is still the one it read. If another writer got there first, it
|
||||
reads again and runs your function again, up to five times. The source is
|
||||
[`tests/GameCult.Caching.Tests/UpdateCallTests.cs`](../../tests/GameCult.Caching.Tests/UpdateCallTests.cs); the
|
||||
snippet reuses `Item` from the first call.
|
||||
|
||||
```csharp
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
|
||||
public static class UpdateCall
|
||||
{
|
||||
public static CultUpdateResult<Item> Restock(string path, CultRecordKey key)
|
||||
{
|
||||
var cache = CultCacheMessagePack.Create(path);
|
||||
return cache.Update<Item>(key, item =>
|
||||
{
|
||||
item ??= new Item { Name = "Potion", Category = "Consumable" };
|
||||
item.Value += 10;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The write is durable when `Update` returns, with no `PushAll`. A lost race re-runs the function, so keep it free of
|
||||
side effects; when the attempts run out the result says `LostRace` instead of throwing. `UpsertAsync` plus
|
||||
`PushAll` is the blind opt-out: last writer wins.
|
||||
|
||||
## TypeScript first call
|
||||
|
||||
Same shape in TypeScript: the document is a name, a schema id and a `parse`-able schema (zod here),
|
||||
|
|
@ -155,7 +124,7 @@ The test imports from `../src/index` where the block reads `@gamecult/cultcache-
|
|||
Same shape with `derive(DatabaseEntry)`: slots are `#[cultcache(key = N)]`, so the `.cc` payload is the
|
||||
slot-indexed array the C# runtime writes. The source is
|
||||
[`packages/cultcache-rs/tests/first_call.rs`](../../packages/cultcache-rs/tests/first_call.rs), run by
|
||||
`cargo test --locked --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block
|
||||
`cargo test --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block
|
||||
below stops matching the file. The Rust runtime has no name lookup, so the read-back is by key. More:
|
||||
`packages/cultcache-rs/README.md`.
|
||||
|
||||
|
|
@ -372,7 +341,7 @@ async function firstCall() {
|
|||
### Rust CultNet first call
|
||||
|
||||
Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by
|
||||
`cargo test --locked --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`.
|
||||
`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`.
|
||||
|
||||
```rust
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
### Breaking
|
||||
|
||||
- `push_all(envelopes, soft=False)` on the single-file and JSONL stores and on the `BackingStore` protocol takes the soft flag, as TypeScript, Rust and C# do: a soft push writes only where nothing is at the path; over a store the reader reads, empty included, it returns without writing, and over a file the reader cannot read it refuses (`StoreUnreadableError` for the single-file store). The `BackingStore` protocol has `pull_or_absent()`: the envelopes the reader reads, `None` when nothing is at the path, a raise when the path cannot be read; its default is `pull_all()`, which cannot say absent, so a store that inherits it is always read. The default `push_all` decides soft by it (writes where it says absent, skips over a store it read, refuses where it raises), and the single-file and JSONL stores expose their pull through it; an inheriting store that can tell nothing-at-the-path overrides `pull_or_absent`, not `push_all`. Before, a soft push through the default always skipped. A store kind that overrides `push_all(envelopes)` without the keyword fails when a caller passes `soft`.
|
||||
- `push_all(envelopes, soft=False)` on the single-file and JSONL stores and on the `BackingStore` protocol takes the soft flag, as TypeScript, Rust and C# do: a soft push writes only where nothing is at the path; over a store the reader reads, empty included, it returns without writing, and over a file the reader cannot read it refuses (`StoreUnreadableError` for the single-file store). The `BackingStore` protocol's default `push_all` cannot tell an absent store from an empty one (a pull returns a list), so a soft push through it always skips a store whose pull returns; a store kind that can tell nothing-at-the-path overrides `push_all`. A store kind that overrides `push_all(envelopes)` without the keyword fails when a caller passes `soft`.
|
||||
- A write at a store path that is a link, live or dangling, is refused with the new `StoreLinkedError` (an `OSError`, exported from `cultcache_py`; `.path` names the path) before the lock and the replace, for the single-file store and for `JsonLinesBackingStore` (`push`, `push_all`, `delete`), and nothing is written. Before, a write at a live link replaced the link with a regular file, and at a dangling link both stores raised `FileNotFoundError`. A caller catching `FileNotFoundError` there now catches `StoreLinkedError` (`OSError`); configure the link's target as the store path. On Windows a junction or any reparse point counts as a link. Reads through a live link work.
|
||||
- A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`.
|
||||
- A payload is one MessagePack value: `DocumentDefinition.decode_payload` refuses bytes after it, by count, with `ValueError` `<type> payload has N bytes after its first MessagePack value (refused on read).` (never the bytes, never the depth refusal; a payload that ends early stays the decoder's), as the C# reference does (`tests/vectors/union-parity/bytes-after-outcomes.msgpack`). It raised `msgpack.ExtraData` (a `ValueError`, so only a caller naming `msgpack.ExtraData` is affected); this is a minor-version bump while pre-1.0. The refused set is unchanged. Cache loads, `get` and every definition built on the MessagePack decoder go through it; a user `payload_decoder` that raises `ExtraData` is refused the same way. Depth is judged first, so a value nested past 256 with a tail is the depth refusal, as in C#.
|
||||
|
|
|
|||
|
|
@ -67,19 +67,12 @@ class BackingStore(Protocol):
|
|||
def delete(self, type: str, key: str) -> None:
|
||||
...
|
||||
|
||||
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
|
||||
"""The envelopes the reader reads (empty included), None when nothing is at the path; raises when the path cannot be read.
|
||||
|
||||
The default is pull_all, which cannot say absent: a store that inherits it is always a store the reader read. A store kind
|
||||
that can tell nothing-at-the-path overrides this, and the default push_all then writes where nothing is on a soft push."""
|
||||
return self.pull_all()
|
||||
|
||||
def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None:
|
||||
# Soft: write only where pull_or_absent says nothing is; skip over a store it read, empty included; its raise refuses.
|
||||
pulled = self.pull_or_absent()
|
||||
if soft and pulled is not None:
|
||||
# A pull that returns is a store the reader read, empty included: the protocol cannot say "absent", so a soft push
|
||||
# through this default always skips. A store that can tell nothing-at-the-path overrides push_all.
|
||||
pulled = self.pull_all()
|
||||
if soft:
|
||||
return
|
||||
pulled = pulled or []
|
||||
existing = {(envelope.type, envelope.key): envelope for envelope in pulled}
|
||||
for envelope in envelopes:
|
||||
existing[(envelope.type, envelope.key)] = envelope
|
||||
|
|
|
|||
|
|
@ -78,9 +78,6 @@ class JsonLinesBackingStore:
|
|||
def pull_all(self) -> list[CultCacheEnvelope]:
|
||||
return self._pull() or []
|
||||
|
||||
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
|
||||
return self._pull()
|
||||
|
||||
def _pull(self) -> list[CultCacheEnvelope] | None:
|
||||
"""The envelopes the reader reads, or None when nothing is at the path."""
|
||||
with self._lock:
|
||||
|
|
@ -155,9 +152,6 @@ class SingleFileMessagePackBackingStore:
|
|||
def pull_all(self) -> list[CultCacheEnvelope]:
|
||||
return self._pull() or []
|
||||
|
||||
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
|
||||
return self._pull()
|
||||
|
||||
def _pull(self) -> list[CultCacheEnvelope] | None:
|
||||
"""The envelopes the reader reads, or None when nothing is at the path."""
|
||||
with self._lock:
|
||||
|
|
|
|||
|
|
@ -69,33 +69,8 @@ class SoftPushAllTests(unittest.TestCase):
|
|||
JsonLinesBackingStore(self.path).push_all([envelope("a")], soft=True)
|
||||
self.assertEqual(junk, self.path.read_bytes())
|
||||
|
||||
def test_the_protocol_default_push_all_decides_soft_by_what_pull_or_absent_says(self) -> None:
|
||||
def test_the_protocol_default_push_all_skips_what_its_pull_read_empty_included(self) -> None:
|
||||
class Memory(BackingStore):
|
||||
def __init__(self) -> None:
|
||||
self.items: list[CultCacheEnvelope] | None = None # None: nothing at the path
|
||||
|
||||
def pull_all(self) -> list[CultCacheEnvelope]:
|
||||
return list(self.items or [])
|
||||
|
||||
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
|
||||
return None if self.items is None else list(self.items)
|
||||
|
||||
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
|
||||
self.items = list(envelopes)
|
||||
|
||||
store = Memory()
|
||||
store.push_all([envelope("a")], soft=True) # nothing at the path: writes
|
||||
self.assertEqual(["a"], [item.key for item in store.items or []])
|
||||
store.push_all([envelope("b")], soft=True) # a store with records: skips
|
||||
self.assertEqual(["a"], [item.key for item in store.items or []])
|
||||
store.items = []
|
||||
store.push_all([envelope("b")], soft=True) # a store the reader read, empty: skips
|
||||
self.assertEqual([], store.items)
|
||||
store.push_all([envelope("b")]) # plain: lands
|
||||
self.assertEqual(["b"], [item.key for item in store.items])
|
||||
|
||||
def test_a_store_that_only_pulls_all_is_read_so_a_soft_push_skips_it(self) -> None:
|
||||
class PullOnly(BackingStore):
|
||||
def __init__(self) -> None:
|
||||
self.items: list[CultCacheEnvelope] = []
|
||||
|
||||
|
|
@ -105,18 +80,14 @@ class SoftPushAllTests(unittest.TestCase):
|
|||
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
|
||||
self.items = list(envelopes)
|
||||
|
||||
store = PullOnly()
|
||||
store.push_all([envelope("a")], soft=True)
|
||||
store = Memory()
|
||||
store.push_all([envelope("a")], soft=True) # the pull read an empty store: skips
|
||||
self.assertEqual([], store.items)
|
||||
|
||||
def test_the_single_file_and_jsonl_stores_say_absent_and_read_through_pull_or_absent(self) -> None:
|
||||
for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
|
||||
with self.subTest(make.__name__):
|
||||
path = self.dir / make.__name__
|
||||
store = make(path)
|
||||
self.assertIsNone(store.pull_or_absent())
|
||||
store.push_all([envelope("a")])
|
||||
self.assertEqual(["a"], [item.key for item in store.pull_or_absent() or []])
|
||||
store.push_all([envelope("a")]) # plain: lands
|
||||
store.push_all([envelope("b")], soft=True) # a store with records: skips
|
||||
self.assertEqual(["a"], [item.key for item in store.items])
|
||||
store.push_all([envelope("b")])
|
||||
self.assertEqual(["a", "b"], sorted(item.key for item in store.items))
|
||||
|
||||
def test_the_protocol_default_push_all_refuses_where_its_pull_refuses(self) -> None:
|
||||
class Unreadable(BackingStore):
|
||||
|
|
@ -125,9 +96,6 @@ class SoftPushAllTests(unittest.TestCase):
|
|||
def pull_all(self) -> list[CultCacheEnvelope]:
|
||||
raise StoreUnreadableError(Path("x"), ValueError("junk"))
|
||||
|
||||
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
|
||||
raise StoreUnreadableError(Path("x"), ValueError("junk"))
|
||||
|
||||
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
|
||||
Unreadable.replaced = True
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -19,7 +18,6 @@ from cultcache_py.backing_store import CultCacheEnvelope
|
|||
REPO = Path(__file__).resolve().parents[3]
|
||||
TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js"
|
||||
HOLD_SECONDS = 1.5
|
||||
HELD_WAIT_SECONDS = 3
|
||||
|
||||
# A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package.
|
||||
PY_HOLDER = """
|
||||
|
|
@ -45,14 +43,7 @@ def envelope(key: str) -> CultCacheEnvelope:
|
|||
|
||||
def start_holder(argv: list[str]) -> subprocess.Popen[str]:
|
||||
holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True)
|
||||
assert holder.stdout is not None
|
||||
ready, _, _ = select.select([holder.stdout], [], [], HELD_WAIT_SECONDS) # a holder blocked on a leaked lock never prints
|
||||
if not ready:
|
||||
holder.kill()
|
||||
holder.wait()
|
||||
holder.stdout.close()
|
||||
raise AssertionError(f"the holder never took {argv[-2]}.lock within {HELD_WAIT_SECONDS} s: an earlier writer leaked the lock")
|
||||
assert holder.stdout.readline().strip() == "HELD"
|
||||
assert holder.stdout is not None and holder.stdout.readline().strip() == "HELD"
|
||||
holder.held_at = time.monotonic() # type: ignore[attr-defined]
|
||||
return holder
|
||||
|
||||
|
|
|
|||
1
packages/cultcache-rs/.gitignore
vendored
1
packages/cultcache-rs/.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
/target
|
||||
Cargo.lock
|
||||
|
|
|
|||
730
packages/cultcache-rs/Cargo.lock
generated
730
packages/cultcache-rs/Cargo.lock
generated
|
|
@ -1,730 +0,0 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "523e5e187fff36fcb8d1d19ccf0ca4ca43ec3d158316057f66dc6f72716bc5ec"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cultcache-rs"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"cultcache-rs-derive",
|
||||
"fs2",
|
||||
"pretty_assertions",
|
||||
"redb",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"serde_stacker",
|
||||
"stacker",
|
||||
"tempfile",
|
||||
"uuid",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cultcache-rs-derive"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diff"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e35d6e78f4cebb90e51d543db8fe41528851d6deeef6ef21ebd4bfdc1e784776"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pretty_assertions"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
|
||||
dependencies = [
|
||||
"diff",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "redb"
|
||||
version = "4.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b317ef681e18f6091efbfab532eaf924a135c492d9d0299d0fbfae07e92a39b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_stacker"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
"stacker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.10",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
|
@ -1861,11 +1861,7 @@ impl SingleFileMessagePackBackingStore {
|
|||
|
||||
fn try_with_exclusive_lock<T>(&self, action: impl FnOnce() -> Result<T>) -> Result<Option<T>> {
|
||||
refuse_linked(&self.path)?;
|
||||
#[cfg(test)]
|
||||
run_after_pre_lock_look();
|
||||
let Some(lock) = self.try_open_lock_file()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let lock = self.open_lock_file()?;
|
||||
match fs2::FileExt::try_lock_exclusive(&lock) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(None),
|
||||
|
|
@ -1883,31 +1879,19 @@ impl SingleFileMessagePackBackingStore {
|
|||
release_lock(lock, refuse_linked(&self.path).and_then(|()| action()))
|
||||
}
|
||||
|
||||
/// Opens the lock file, waiting through a Windows sharing violation the way the lock itself waits.
|
||||
fn open_lock_file(&self) -> Result<File> {
|
||||
let lock_path = self.prepare_lock_dir()?;
|
||||
retry_while_busy(
|
||||
|| open_lock_once(&lock_path),
|
||||
is_sharing_violation,
|
||||
|| std::thread::sleep(std::time::Duration::from_millis(5)),
|
||||
)
|
||||
.with_context(|| format!("failed to open {}", lock_path.display()))
|
||||
}
|
||||
|
||||
/// The single-attempt open for the try entry: a sharing violation is `None`, the same answer as a held lock.
|
||||
fn try_open_lock_file(&self) -> Result<Option<File>> {
|
||||
let lock_path = self.prepare_lock_dir()?;
|
||||
open_unless_busy(|| open_lock_once(&lock_path), is_sharing_violation)
|
||||
.with_context(|| format!("failed to open {}", lock_path.display()))
|
||||
}
|
||||
|
||||
fn prepare_lock_dir(&self) -> Result<PathBuf> {
|
||||
let lock_path = self.lock_path();
|
||||
if let Some(parent) = lock_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
Ok(lock_path)
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&lock_path)
|
||||
.with_context(|| format!("failed to open {}", lock_path.display()))
|
||||
}
|
||||
|
||||
fn lock_path(&self) -> PathBuf {
|
||||
|
|
@ -4562,53 +4546,6 @@ impl ReplaceKernel for WindowsKernel {
|
|||
}
|
||||
}
|
||||
|
||||
fn open_lock_once(lock_path: &Path) -> std::io::Result<File> {
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(lock_path)
|
||||
}
|
||||
|
||||
/// Windows ERROR_SHARING_VIOLATION (raw os error 32): another handle holds the lock file open without sharing. On Unix
|
||||
/// errno 32 is EPIPE and is never a busy file.
|
||||
#[cfg(windows)]
|
||||
fn is_sharing_violation(error: &std::io::Error) -> bool {
|
||||
error.raw_os_error() == Some(32)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_sharing_violation(_error: &std::io::Error) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Repeats `open` while it fails busy, sleeping between tries and without a bound, as the lock waits; any other error surfaces.
|
||||
fn retry_while_busy<T>(
|
||||
mut open: impl FnMut() -> std::io::Result<T>,
|
||||
busy: impl Fn(&std::io::Error) -> bool,
|
||||
mut sleep: impl FnMut(),
|
||||
) -> std::io::Result<T> {
|
||||
loop {
|
||||
match open() {
|
||||
Err(error) if busy(&error) => sleep(),
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One attempt: `None` when the open fails busy, any other error surfaces.
|
||||
fn open_unless_busy<T>(
|
||||
open: impl FnOnce() -> std::io::Result<T>,
|
||||
busy: impl Fn(&std::io::Error) -> bool,
|
||||
) -> std::io::Result<Option<T>> {
|
||||
match open() {
|
||||
Ok(file) => Ok(Some(file)),
|
||||
Err(error) if busy(&error) => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod replace_with_tests {
|
||||
use super::{replace_with, ReplaceKernel};
|
||||
|
|
@ -4836,20 +4773,6 @@ fn injected_write_fault(step: WriteStep) -> std::io::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// The one gap a test cannot hold open from outside: a try-lock never waits, so what happens between its pre-lock look and
|
||||
// the lock is only reachable by running the test's closure there.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static AFTER_PRE_LOCK_LOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn run_after_pre_lock_look() {
|
||||
if let Some(hook) = AFTER_PRE_LOCK_LOOK.with(|hook| hook.borrow_mut().take()) {
|
||||
hook();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[inline(always)]
|
||||
fn injected_write_fault(_step: WriteStep) -> std::io::Result<()> {
|
||||
|
|
@ -8322,138 +8245,6 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn busy_error() -> std::io::Error {
|
||||
std::io::Error::from_raw_os_error(32)
|
||||
}
|
||||
|
||||
fn is_busy(error: &std::io::Error) -> bool {
|
||||
error.raw_os_error() == Some(32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lock_open_waits_through_busy_failures_then_opens() {
|
||||
let (mut tries, mut sleeps) = (0, 0);
|
||||
let opened = retry_while_busy(
|
||||
|| {
|
||||
tries += 1;
|
||||
if tries <= 4 { Err(busy_error()) } else { Ok(tries) }
|
||||
},
|
||||
is_busy,
|
||||
|| sleeps += 1,
|
||||
);
|
||||
assert_eq!(opened.unwrap(), 5);
|
||||
assert_eq!(sleeps, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lock_open_surfaces_a_non_busy_error_at_once() {
|
||||
let (mut tries, mut sleeps) = (0, 0);
|
||||
let error = retry_while_busy::<()>(
|
||||
|| {
|
||||
tries += 1;
|
||||
Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
|
||||
},
|
||||
is_busy,
|
||||
|| sleeps += 1,
|
||||
)
|
||||
.expect_err("a non-busy error must surface");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
assert_eq!((tries, sleeps), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_try_open_maps_a_sharing_violation_to_none_without_retrying() {
|
||||
let mut tries = 0;
|
||||
let opened = open_unless_busy::<()>(
|
||||
|| {
|
||||
tries += 1;
|
||||
Err(busy_error())
|
||||
},
|
||||
is_busy,
|
||||
);
|
||||
assert!(opened.unwrap().is_none());
|
||||
assert_eq!(tries, 1);
|
||||
let other = open_unless_busy::<()>(|| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)), is_busy);
|
||||
assert!(other.is_err());
|
||||
}
|
||||
|
||||
// The try entry must answer at once while another handle holds the lock. The attempt runs on a thread and its
|
||||
// answer is awaited with a timeout, so an entry that waits fails this test by timeout instead of hanging the suite.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_try_entry_returns_at_once_while_the_lock_is_held() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("held.cc");
|
||||
let store = SingleFileMessagePackBackingStore::new(&path);
|
||||
let holder = store.open_lock_file()?;
|
||||
fs2::FileExt::lock_exclusive(&holder)?;
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let attempt = SingleFileMessagePackBackingStore::new(&path);
|
||||
std::thread::spawn(move || {
|
||||
let _ = sender.send(attempt.try_compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")]));
|
||||
});
|
||||
let outcome = receiver
|
||||
.recv_timeout(std::time::Duration::from_secs(3))
|
||||
.map_err(|_| anyhow!("the try entry waited on a held lock instead of returning"))?;
|
||||
assert!(matches!(outcome?, TryCompareExchangeSnapshotOutcome::LockContended));
|
||||
assert!(!path.exists(), "the contended attempt wrote");
|
||||
let again = store.open_lock_file()?;
|
||||
assert!(fs2::FileExt::try_lock_exclusive(&again).is_err(), "the held lock was disturbed");
|
||||
fs2::FileExt::unlock(&holder)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Off Windows errno 32 is EPIPE, never a busy file: the predicate is false and the wait surfaces it on the first try.
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn error_32_is_not_busy_off_windows_and_surfaces_at_once() {
|
||||
assert!(!is_sharing_violation(&busy_error()));
|
||||
let (mut tries, mut sleeps) = (0, 0);
|
||||
let error = retry_while_busy::<()>(
|
||||
|| {
|
||||
tries += 1;
|
||||
Err(busy_error())
|
||||
},
|
||||
is_sharing_violation,
|
||||
|| sleeps += 1,
|
||||
)
|
||||
.expect_err("error 32 must surface off Windows");
|
||||
assert_eq!(error.raw_os_error(), Some(32));
|
||||
assert_eq!((tries, sleeps), (1, 0));
|
||||
}
|
||||
|
||||
// The try entry has no wait, so the late window is the gap between its look and its lock: a link put there is refused
|
||||
// once the lock is held, nothing is written through or over it, and the lock is released.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_put_between_the_try_entrys_look_and_its_lock_is_refused_and_the_lock_is_released() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let target = temp.path().join("target.cc");
|
||||
SingleFileMessagePackBackingStore::new(&target).push(&snapshot_envelope("a", b"1"))?;
|
||||
let before = fs::read(&target)?;
|
||||
let dangling = temp.path().join("missing.cc");
|
||||
let path = temp.path().join("late.cc");
|
||||
for link_to in [target.clone(), dangling.clone()] {
|
||||
let store = SingleFileMessagePackBackingStore::new(&path);
|
||||
let (link, at) = (link_to.clone(), path.clone());
|
||||
AFTER_PRE_LOCK_LOOK.with(|hook| {
|
||||
*hook.borrow_mut() = Some(Box::new(move || std::os::unix::fs::symlink(&link, &at).expect("put the link")));
|
||||
});
|
||||
let error = store
|
||||
.try_compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")])
|
||||
.expect_err("the late link must be refused");
|
||||
assert!(error.downcast_ref::<StoreLinkedError>().is_some(), "{error:#}");
|
||||
assert!(fs::symlink_metadata(&path)?.file_type().is_symlink(), "the link was replaced");
|
||||
assert_eq!(fs::read(&target)?, before, "the target changed");
|
||||
assert!(!dangling.exists(), "the dangling target was created");
|
||||
let probe = store.open_lock_file()?;
|
||||
fs2::FileExt::try_lock_exclusive(&probe).map_err(|e| anyhow!("lock still held: {e}"))?;
|
||||
drop(probe);
|
||||
fs::remove_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// A write at a store path that is a link is refused: a replace there would swap the link for a regular file and
|
||||
// detach the store from its target. Reads through a live link keep working.
|
||||
#[cfg(unix)]
|
||||
|
|
|
|||
|
|
@ -2106,7 +2106,7 @@ async function readTsInteropStore(file: string): Promise<InteropNote> {
|
|||
|
||||
async function buildInteropPeers(): Promise<void> {
|
||||
if (!(await exists(rustInteropBinary))) {
|
||||
await execAsync(`"${cargoCommand}" build --locked --quiet --example cultcache_interop`, {
|
||||
await execAsync(`"${cargoCommand}" build --quiet --example cultcache_interop`, {
|
||||
cwd: cultcacheRsRoot,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,8 +147,8 @@ CultMath names with tests instead of copied project-local helper drift.
|
|||
```powershell
|
||||
dotnet build packages/cultmath/src/CultMath/CultMath.csproj
|
||||
dotnet test packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj
|
||||
cargo test --locked --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml
|
||||
cargo build --locked --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml
|
||||
cargo test --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml
|
||||
cargo build --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml
|
||||
.\packages\cultmath\scripts\build-unity-package.ps1
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ const rust: Target = {
|
|||
name: "rust",
|
||||
payload: (text) => encode({ text }),
|
||||
async start() {
|
||||
if (!prebuilt) await execFileAsync(cargo, ["build", "--locked", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir });
|
||||
if (!prebuilt) await execFileAsync(cargo, ["build", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir });
|
||||
const metadata = spawnSync(cargo, ["metadata", "--format-version", "1", "--no-deps"], { cwd: rustManifestDir, encoding: "utf8" });
|
||||
if (metadata.status !== 0) throw new Error(`cargo metadata failed: ${metadata.stderr}`);
|
||||
const exe = join(JSON.parse(metadata.stdout).target_directory, "debug", "examples", `receipt_interop_server${process.platform === "win32" ? ".exe" : ""}`);
|
||||
|
|
|
|||
1
packages/cultnet-rs/.gitignore
vendored
1
packages/cultnet-rs/.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
/target
|
||||
Cargo.lock
|
||||
|
|
|
|||
1227
packages/cultnet-rs/Cargo.lock
generated
1227
packages/cultnet-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -30,7 +30,7 @@ knock on the contract before touching the furniture.
|
|||
## Receipts
|
||||
|
||||
```powershell
|
||||
cargo test --locked
|
||||
cargo test
|
||||
```
|
||||
|
||||
The initial tests prove:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! traffic crosses the same schedule. The schedule is `policy.rs`.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --locked --release --example cultnet_impair -- \
|
||||
//! cargo run --release --example cultnet_impair -- \
|
||||
//! --listen 0.0.0.0:17890 --upstream 127.0.0.1:17990 \
|
||||
//! --profile tests/impairment/loss-1pct.toml --seed 424242 --metrics loss-1pct.csv
|
||||
//! ```
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! Ignored by default; run in release, where the numbers mean something:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --locked --release --test media_fec_bench -- --ignored --nocapture
|
||||
//! cargo test --release --test media_fec_bench -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Each line is nanoseconds per call through the public surface, so record
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ cultnet-rs only, and Idunn's `IdunnRuntimeActivationLaunch` is that same crate's
|
|||
It needs Linux and a machine-id equal to the `machine-id` file here, because the
|
||||
provider identity is bound to it. On Yggdrasil:
|
||||
|
||||
DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh <CultLib> HEAD rust 'cd packages/cultnet-rs && cp <fixed-machine-id> /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run --locked -q --example idunn_runtime_fixture /tmp/out'
|
||||
DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh <CultLib> HEAD rust 'cd packages/cultnet-rs && cp <fixed-machine-id> /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run -q --example idunn_runtime_fixture /tmp/out'
|
||||
|
||||
then copy `/tmp/out` over this directory. `--cap-add SYS_ADMIN` exists only so the bind mount can present the committed `machine-id`, which keeps Yggdrasil's real machine-id out of the repo. Every `presence-*.bin` was accepted by
|
||||
`authenticate_runtime_presence_claim` and correlated by
|
||||
|
|
@ -26,7 +26,7 @@ The generator takes its specification as input, so a consumer needs no patch to
|
|||
specification is command-line flags; `@<file>` splices in a response file (one `--flag value`
|
||||
per line, `#` comments). CultLib's own sets are `fixture.args` here:
|
||||
|
||||
cargo run --locked -q --example idunn_runtime_fixture /tmp/out @<path>/fixture.args
|
||||
cargo run -q --example idunn_runtime_fixture /tmp/out @<path>/fixture.args
|
||||
|
||||
The consumer keeps its own `.args` file in its repository and runs the same command with the
|
||||
same machine-id bind mount as above. Global flags: `--source-repository`, `--detail`. Each
|
||||
|
|
|
|||
|
|
@ -2795,7 +2795,7 @@ async function buildInteropPeers(): Promise<void> {
|
|||
}
|
||||
|
||||
async function buildRustInteropPeer(): Promise<void> {
|
||||
rustInteropPeerBuild ??= execFileAsync(cargoCommand, ["build", "--locked", "--quiet", "--example", "cultnet_interop_peer"], {
|
||||
rustInteropPeerBuild ??= execFileAsync(cargoCommand, ["build", "--quiet", "--example", "cultnet_interop_peer"], {
|
||||
cwd: cultnetRsRoot,
|
||||
}).then(() => undefined);
|
||||
await rustInteropPeerBuild;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//
|
||||
// The vectors below were produced by `cultnet-rs`, not written by hand:
|
||||
//
|
||||
// cargo run --locked --example runtime_presence_vector -p cultnet-rs
|
||||
// cargo run --example runtime_presence_vector -p cultnet-rs
|
||||
//
|
||||
// That matters. A fixture authored for both sides at once agrees with itself
|
||||
// and proves nothing; the contract is only real if one fork emits the bytes and
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ plus the rules that need no store to state (`Answer::is_training_data`, `Answer:
|
|||
(comments 682 and 759). Specification only: no collector, tally, export or tombstone hashing lives here.
|
||||
|
||||
Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, run by `tests/golden.rs`
|
||||
(`cargo test --locked`). Planned, not live: the collector, the tally, the training export.
|
||||
(`cargo test`). Planned, not live: the collector, the tally, the training export.
|
||||
|
||||
## Where each ruling lands (doc lines at aaeb175)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ adversary, storage, and any projection. Every parameter is a placeholder until t
|
|||
## First call
|
||||
|
||||
```
|
||||
cargo run --locked --example first_call # prints the Meta example and a splitting table
|
||||
cargo test --locked
|
||||
cargo run --example first_call # prints the Meta example and a splitting table
|
||||
cargo test
|
||||
```
|
||||
|
||||
```rust
|
||||
|
|
|
|||
|
|
@ -2052,29 +2052,6 @@ namespace GameCult.Caching
|
|||
Contended
|
||||
}
|
||||
|
||||
public enum CultUpdateOutcome
|
||||
{
|
||||
Updated,
|
||||
Declined,
|
||||
LostRace
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="CultCache.Update{T}"/> did. A lost race is a value, not an exception. <see cref="Document"/> is the instance the
|
||||
/// cache holds at the key when Update returns: the written one for Updated, null when the key is absent.
|
||||
/// </summary>
|
||||
public readonly struct CultUpdateResult<T> where T : class
|
||||
{
|
||||
public CultUpdateResult(CultUpdateOutcome outcome, T? document)
|
||||
{
|
||||
Outcome = outcome;
|
||||
Document = document;
|
||||
}
|
||||
|
||||
public CultUpdateOutcome Outcome { get; }
|
||||
public T? Document { get; }
|
||||
}
|
||||
|
||||
public sealed class CultCommitRequest
|
||||
{
|
||||
internal CultCommitRequest(
|
||||
|
|
@ -2586,7 +2563,6 @@ namespace GameCult.Caching
|
|||
return UpsertAsync(document, handle);
|
||||
}
|
||||
|
||||
// A blind write, last-writer-wins per key; Update is the conditional golden path.
|
||||
// Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares.
|
||||
public Task<CultRecordHandle<T>> UpsertAsync<T>(T document, CultRecordHandle<T>? handle = null)
|
||||
{
|
||||
|
|
@ -2663,7 +2639,6 @@ namespace GameCult.Caching
|
|||
|
||||
/// <summary>
|
||||
/// Commits the staged batch. Returns false when a condition failed: nothing was written, changed in memory, or published.
|
||||
/// Without <c>Expect</c> a commit is blind; <see cref="Update{T}"/> retries a conditional one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A commit that succeeds and then fails in a journal or observer still stands and is published, and the writer
|
||||
|
|
@ -2681,59 +2656,6 @@ namespace GameCult.Caching
|
|||
return Land(stage, wait: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the record at <paramref name="key"/>, applies <paramref name="apply"/> to a private copy of it, and commits the result only
|
||||
/// if the record is still the one that was read; when another writer got there first it re-reads and runs <paramref name="apply"/>
|
||||
/// again, up to <paramref name="maxAttempts"/> times. The write is durable when Update returns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="apply"/> may run up to <paramref name="maxAttempts"/> times. It gets a private copy (null when the key holds
|
||||
/// nothing), which it may change and return. It must not write to the cache or have other side effects. Return null to write
|
||||
/// nothing. A store with staged blind writes refuses Update until it is flushed, as it refuses any conditional commit. The cache
|
||||
/// needs a codec to copy with. Running out of attempts returns <see cref="CultUpdateOutcome.LostRace"/>; every other failure throws.
|
||||
/// </remarks>
|
||||
public CultUpdateResult<T> Update<T>(CultRecordKey key, Func<T?, T?> apply, int maxAttempts = 5) where T : class
|
||||
{
|
||||
if (apply == null) throw new ArgumentNullException(nameof(apply));
|
||||
if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "At least one attempt is required.");
|
||||
var codec = _codec ?? throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create.");
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
var (version, copy) = Held(() =>
|
||||
{
|
||||
if (!_entries.TryGetValue(key.Value, out var held))
|
||||
return ((string?)null, (T?)null);
|
||||
if (held.Variant != null || held.Document is not T)
|
||||
throw new InvalidOperationException($"Record {key.Value} is a variant or not a {typeof(T).Name}; Update cannot copy it.");
|
||||
return (held.StoredVersion, (T?)codec.Clone(held.Document, held.Descriptor.DocumentType));
|
||||
});
|
||||
var next = apply(copy);
|
||||
if (next == null)
|
||||
return new CultUpdateResult<T>(CultUpdateOutcome.Declined, Get<T>(key));
|
||||
CultCommitOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = Land(batch =>
|
||||
{
|
||||
batch.Expected.Add((key, version));
|
||||
batch.Upsert(next, new CultRecordHandle<T>(key));
|
||||
}, wait: true);
|
||||
}
|
||||
catch (CultWriteConflictException conflict) when (conflict.InnerException == null)
|
||||
{
|
||||
outcome = CultCommitOutcome.Mismatch;
|
||||
}
|
||||
|
||||
if (outcome == CultCommitOutcome.Committed)
|
||||
return new CultUpdateResult<T>(CultUpdateOutcome.Updated, next);
|
||||
if (outcome != CultCommitOutcome.Mismatch)
|
||||
throw new InvalidOperationException($"Update of {key.Value} was refused: {outcome}.");
|
||||
Held(() => { Home(next.GetType())?.PullAll(); return true; });
|
||||
if (attempt >= maxAttempts)
|
||||
return new CultUpdateResult<T>(CultUpdateOutcome.LostRace, Get<T>(key));
|
||||
}
|
||||
}
|
||||
|
||||
public CultRecordHandle<T>? TryGetHandle<T>(T document)
|
||||
{
|
||||
if (document == null) throw new ArgumentNullException(nameof(document));
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ namespace GameCult.Caching
|
|||
}
|
||||
}
|
||||
|
||||
public object Clone(object document, Type type) => _codec.Clone(document, type);
|
||||
public object Clone(object document, Type type) => _deserialize(type, _serialize(document, type, type));
|
||||
|
||||
public CultInspectorEdit BeginEdit(CultStoredDocument record)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -29,11 +29,6 @@ namespace GameCult.Caching
|
|||
// (document payload, slot and encoded value pairs)
|
||||
public Func<byte[], IReadOnlyList<KeyValuePair<int, byte[]>>, byte[]> Overlay { get; }
|
||||
|
||||
// The one copy of a document: its encoding, decoded again. Update's private argument and CultInspectorModel.Clone share it.
|
||||
internal object Clone(object document, Type type) =>
|
||||
Deserialize(type, type, Serialize(document, type, type))
|
||||
?? throw new InvalidOperationException($"The codec returned null for {type.FullName}.");
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a document payload read from a store file, as a cache load decodes it: the ids a load mints are minted from the key
|
||||
/// and their path, so every reader of one store mints the same ids, and a payload a cache write would refuse is refused here too.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ Cacheable models are plain classes annotated with cache intent.
|
|||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
[MessagePackObject]
|
||||
[CultDocument("gamecult.item_data", "gamecult.item_data.v1")]
|
||||
public sealed class ItemData
|
||||
{
|
||||
|
|
@ -132,33 +131,18 @@ using GameCult.Caching;
|
|||
using GameCult.Caching.MessagePack;
|
||||
|
||||
var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack");
|
||||
var key = new CultRecordKey("potion");
|
||||
|
||||
// The golden path: read, change a private copy, commit only if the record is unchanged, re-run on a lost race.
|
||||
// Durable when it returns; no flush.
|
||||
var result = cache.Update<ItemData>(key, item =>
|
||||
{
|
||||
item ??= new ItemData { Name = "Potion", Sku = "POT-1", Category = "Consumable" };
|
||||
item.Value += 10;
|
||||
return item;
|
||||
});
|
||||
|
||||
// The blind opt-out: last writer wins per key. Name the key, or a new key claims the same unique Sku and is refused.
|
||||
var handle = await cache.UpsertAsync(new ItemData
|
||||
{
|
||||
Name = "Potion",
|
||||
Sku = "POT-1",
|
||||
Category = "Consumable",
|
||||
Value = 50
|
||||
}, new CultRecordHandle<ItemData>(key));
|
||||
});
|
||||
|
||||
await cache.FlushAsync();
|
||||
```
|
||||
|
||||
`Update` may run your function more than once, so it must have no side effects. Returning null writes nothing, and a
|
||||
lost race that outlasts `maxAttempts` comes back as `CultUpdateOutcome.LostRace`. `UpsertAsync` plus `FlushAsync` never
|
||||
checks what it overwrites.
|
||||
|
||||
If you want the explicit assembly rite, the lower-level `CultCache` +
|
||||
`SingleFileMessagePackBackingStore` path is still there. The helper is just the
|
||||
blessed front door.
|
||||
|
|
@ -310,10 +294,13 @@ Useful surfaces:
|
|||
- `cache.IsDirty`
|
||||
- `store.IsDirty`
|
||||
- `cache.Document<T>(key)`
|
||||
- `cache.Update<T>(key, apply)`
|
||||
- `cache.UpsertAsync(document)` (blind write)
|
||||
- `cache.Soa<T>()`
|
||||
- `cache.UpsertAsync(document)`
|
||||
- `cache.FlushAllBackingStores()`
|
||||
- `cache.FlushAsync()`
|
||||
- `cache.FlushBackingStore(store)`
|
||||
- `cache.PrepareForReloadOrShutdown()`
|
||||
- `cache.PrepareForReloadOrShutdownAsync()`
|
||||
- `cache.FlushAttachedStoresOnDispose`
|
||||
- `store.FlushOnDispose`
|
||||
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.Serialization;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// The id walk's own contracts, one mutant-sized fact at a time: the order of the id path, a union that names itself, a list of
|
||||
// values never counting as an id, and the attribute predicates that decide whether a type is walked or written whole.
|
||||
public class ElementIdWalkTests
|
||||
{
|
||||
// The codec asks MessagePack about the id path in order and refuses at the first type it cannot allow, so the order is what
|
||||
// an error names first: the document, then the collection types from the outermost in, then the element and its arms.
|
||||
[Test]
|
||||
public void TheIdPathListsCollectionsFromTheOutermostIn()
|
||||
{
|
||||
Assert.That(CultElementIds.Inspect(typeof(MsMapMap)).IdPath, Is.EqualTo(new[]
|
||||
{
|
||||
typeof(MsMapMap), typeof(Dictionary<string, Dictionary<string, List<ElementIdEngineTests.EIded>>>),
|
||||
typeof(Dictionary<string, List<ElementIdEngineTests.EIded>>), typeof(List<ElementIdEngineTests.EIded>), typeof(ElementIdEngineTests.EIded)
|
||||
}), "maps nested in maps");
|
||||
Assert.That(CultElementIds.Inspect(typeof(MsListDeep)).IdPath, Is.EqualTo(new[]
|
||||
{
|
||||
typeof(MsListDeep), typeof(List<List<List<ElementIdEngineTests.EIded>>>), typeof(List<List<ElementIdEngineTests.EIded>>),
|
||||
typeof(List<ElementIdEngineTests.EIded>), typeof(ElementIdEngineTests.EIded)
|
||||
}), "lists nested in lists");
|
||||
}
|
||||
|
||||
// ConcreteTypes stops at a type it has already expanded, so a union that lists its own base is finished, not a stack overflow.
|
||||
[Test]
|
||||
public void AUnionThatNamesItselfIsWalkedOnce()
|
||||
{
|
||||
var survey = CultElementIds.Inspect(typeof(MsSelfHolder));
|
||||
Assert.That(survey.Problems, Is.Empty);
|
||||
Assert.That(survey.IdPath, Is.EquivalentTo(new[] { typeof(MsSelfHolder), typeof(List<MsSelfBase>), typeof(MsSelfBase), typeof(MsSelfArm) }));
|
||||
}
|
||||
|
||||
// Value lists hold no ids however they are nested, and a root that is itself a value list holds none either.
|
||||
[Test]
|
||||
public void ListsOfValuesHoldNoIds()
|
||||
{
|
||||
var values = new MsValues { Numbers = { 1 }, Tags = { "a" }, Grid = { new List<int> { 1 } }, Map = { ["k"] = new List<int> { 2 } } };
|
||||
Assert.That(CultElementIds.Holds(typeof(MsValues), values), Is.False);
|
||||
Assert.That(CultElementIds.Holds(typeof(List<int>), new List<int> { 1, 2 }), Is.False);
|
||||
Assert.That(CultElementIds.Holds(typeof(List<List<int>>), new List<List<int>> { new() { 1 } }), Is.False);
|
||||
Assert.That(CultElementIds.Holds(typeof(List<string>), new List<string> { "a" }), Is.False);
|
||||
Assert.That(CultElementIds.Holds(typeof(MsBoom), new MsBoom()), Is.False, "a sequence of values is not enumerated");
|
||||
}
|
||||
|
||||
// A nullable struct is walked as the struct it wraps; a nullable value is not walked at all.
|
||||
[Test]
|
||||
public void ANullableKeyedStructWalksAsItsOwner()
|
||||
{
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsKeyedStruct)), Is.True);
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsKeyedStruct?)), Is.True);
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(int?)), Is.False);
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(int)), Is.False);
|
||||
}
|
||||
|
||||
// [Key] is read through an override, so an element whose id property overrides a keyed base member is still an id with a key.
|
||||
[Test]
|
||||
public void AKeyOnTheBaseOfAnOverrideCountsForTheOverride()
|
||||
{
|
||||
Assert.That(CultElementIds.Inspect(typeof(MsOverrideHolder)).Problems, Is.Empty);
|
||||
Assert.That(CultElementIds.Inspect(typeof(MsOverrideHolder)).IdPath, Does.Contain(typeof(MsOverrideEl)));
|
||||
}
|
||||
|
||||
// A type is walked by key when it is [MessagePackObject] or [Union], read through its bases for the former; it is one whole
|
||||
// value when it declares its own formatter, is [DataContract] without being [MessagePackObject], or is keyed by name.
|
||||
[Test]
|
||||
public void KeyedTypesWalkAndWholeTypesDoNot()
|
||||
{
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsMpBase)), Is.True, "[MessagePackObject]");
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsUBase)), Is.True, "[Union]");
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsMpDerived)), Is.True, "[MessagePackObject] inherited");
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsPlain)), Is.False, "[Key] members without [MessagePackObject] or [Union]");
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsUArm)), Is.True, "an arm that is itself [MessagePackObject]");
|
||||
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsDc)), Is.False, "[DataContract] alone");
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsDcMp)), Is.True, "[DataContract] beside [MessagePackObject]");
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsDcOverMpBase)), Is.True, "[DataContract] over an inherited [MessagePackObject]");
|
||||
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsPropName)), Is.False, "KeyAsPropertyName");
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsPropNameDerived)), Is.False, "KeyAsPropertyName inherited");
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsStringKey)), Is.False, "a string [Key]");
|
||||
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsFormattedBase)), Is.False, "[MessagePackFormatter]");
|
||||
Assert.That(CultElementIds.WalksAsOwner(typeof(MsFormattedDerived)), Is.False, "[MessagePackFormatter] inherited");
|
||||
}
|
||||
|
||||
[MessagePackObject] public class MsMapMap { [Key(0)] public Dictionary<string, Dictionary<string, List<ElementIdEngineTests.EIded>>> M { get; set; } = new(); }
|
||||
[MessagePackObject] public class MsListDeep { [Key(0)] public List<List<List<ElementIdEngineTests.EIded>>> G { get; set; } = new(); }
|
||||
|
||||
[Union(0, typeof(MsSelfBase)), Union(1, typeof(MsSelfArm))] public abstract class MsSelfBase { }
|
||||
[MessagePackObject] public class MsSelfArm : MsSelfBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; }
|
||||
[MessagePackObject] public class MsSelfHolder { [Key(0)] public List<MsSelfBase> Items { get; set; } = new(); }
|
||||
|
||||
[MessagePackObject]
|
||||
public class MsValues
|
||||
{
|
||||
[Key(0)] public List<int> Numbers { get; set; } = new();
|
||||
[Key(1)] public List<string> Tags { get; set; } = new();
|
||||
[Key(2)] public List<List<int>> Grid { get; set; } = new();
|
||||
[Key(3)] public Dictionary<string, List<int>> Map { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MsBoom : IEnumerable<int>
|
||||
{
|
||||
public IEnumerator<int> GetEnumerator() => throw new InvalidOperationException("enumerated");
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
[MessagePackObject] public struct MsKeyedStruct { [Key(0)] public int X; }
|
||||
|
||||
[MessagePackObject]
|
||||
public class MsOverrideBase
|
||||
{
|
||||
[Key(0)] [CultElementId] public virtual string Id { get; set; } = "";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class MsOverrideEl : MsOverrideBase
|
||||
{
|
||||
public override string Id { get; set; } = "";
|
||||
}
|
||||
|
||||
[MessagePackObject] public class MsOverrideHolder { [Key(0)] public List<MsOverrideEl> Items { get; set; } = new(); }
|
||||
|
||||
[MessagePackObject] public class MsMpBase { [Key(0)] public int A { get; set; } }
|
||||
public class MsMpDerived : MsMpBase { }
|
||||
public class MsPlain { [Key(0)] public int A { get; set; } }
|
||||
[Union(0, typeof(MsUArm))] public abstract class MsUBase { }
|
||||
[MessagePackObject] public class MsUArm : MsUBase { [Key(0)] public int A { get; set; } }
|
||||
|
||||
[DataContract] public class MsDc { [DataMember] public int A { get; set; } }
|
||||
[DataContract] [MessagePackObject] public class MsDcMp { [Key(0)] public int A { get; set; } }
|
||||
[DataContract] public class MsDcOverMpBase : MsMpBase { }
|
||||
|
||||
[MessagePackObject(keyAsPropertyName: true)] public class MsPropName { public int A { get; set; } }
|
||||
public class MsPropNameDerived : MsPropName { }
|
||||
[MessagePackObject] public class MsStringKey { [Key("a")] public int A { get; set; } }
|
||||
|
||||
public sealed class MsNoFormatter { }
|
||||
[MessagePackFormatter(typeof(MsNoFormatter))] [MessagePackObject] public class MsFormattedBase { [Key(0)] public int A { get; set; } }
|
||||
[MessagePackObject] public class MsFormattedDerived : MsFormattedBase { [Key(1)] public int B { get; set; } }
|
||||
}
|
||||
}
|
||||
|
|
@ -62,28 +62,17 @@ namespace GameCult.Caching.Tests
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void DocsShowTheTestedSnippet() => StartDocs.AssertShowsSnippet(SourcePath());
|
||||
|
||||
static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path;
|
||||
}
|
||||
|
||||
// docs/start/README.md shows each tested snippet verbatim: the region between snippet:begin and snippet:end of its source file.
|
||||
internal static class StartDocs
|
||||
{
|
||||
public static void AssertShowsSnippet(string sourcePath) =>
|
||||
AssertShowsSnippet(sourcePath, Path.Combine("..", "..", "docs", "start", "README.md"));
|
||||
|
||||
// The same check against another document, its path relative to the directory of sourcePath. The snippet there is the
|
||||
// statements of a block, so it need not open the fence (the block's usings come first).
|
||||
public static void AssertShowsSnippet(string sourcePath, string docsRelativePath, bool wholeBlock = true)
|
||||
public void DocsShowTheTestedSnippet()
|
||||
{
|
||||
var source = File.ReadAllText(sourcePath).Replace("\r\n", "\n");
|
||||
var source = File.ReadAllText(SourcePath());
|
||||
const string begin = "// snippet:begin\n";
|
||||
var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length;
|
||||
var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)];
|
||||
var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourcePath)!, docsRelativePath));
|
||||
Assert.That(docs.Replace("\r\n", "\n"), Does.Contain(wholeBlock ? "```csharp\n" + snippet + "```" : snippet),
|
||||
$"{docsRelativePath} no longer shows {Path.GetFileName(sourcePath)} verbatim");
|
||||
var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..", "..", "docs", "start", "README.md"));
|
||||
Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"),
|
||||
"docs/start/README.md no longer shows tests/GameCult.Caching.Tests/FirstCallTests.cs verbatim");
|
||||
}
|
||||
|
||||
static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// The Happy Path of src/GameCult.Caching/README.md, run as written: its statements are the region between snippet:begin and
|
||||
// snippet:end below, and ReadmeShowsTheTestedSnippet fails when the README stops showing them verbatim.
|
||||
public class ReadmeHappyPathTests
|
||||
{
|
||||
[CultDocument("gamecult.item_data", "gamecult.item_data.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class ItemData
|
||||
{
|
||||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
[Key(1)] [CultIndex] public string Sku = string.Empty;
|
||||
[Key(2)] public string Category = string.Empty;
|
||||
[Key(3)] public int Value;
|
||||
}
|
||||
|
||||
private static async Task<CultCache> HappyPath()
|
||||
{
|
||||
// snippet:begin
|
||||
var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack");
|
||||
var key = new CultRecordKey("potion");
|
||||
|
||||
// The golden path: read, change a private copy, commit only if the record is unchanged, re-run on a lost race.
|
||||
// Durable when it returns; no flush.
|
||||
var result = cache.Update<ItemData>(key, item =>
|
||||
{
|
||||
item ??= new ItemData { Name = "Potion", Sku = "POT-1", Category = "Consumable" };
|
||||
item.Value += 10;
|
||||
return item;
|
||||
});
|
||||
|
||||
// The blind opt-out: last writer wins per key. Name the key, or a new key claims the same unique Sku and is refused.
|
||||
var handle = await cache.UpsertAsync(new ItemData
|
||||
{
|
||||
Name = "Potion",
|
||||
Sku = "POT-1",
|
||||
Category = "Consumable",
|
||||
Value = 50
|
||||
}, new CultRecordHandle<ItemData>(key));
|
||||
|
||||
await cache.FlushAsync();
|
||||
// snippet:end
|
||||
return cache;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HappyPathRunsAsWritten()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"readme-happy-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
var previous = Environment.CurrentDirectory;
|
||||
try
|
||||
{
|
||||
Environment.CurrentDirectory = directory; // the README opens a relative path
|
||||
using var cache = await HappyPath();
|
||||
Assert.That(cache.Get<ItemData>(new CultRecordKey("potion"))!.Value, Is.EqualTo(50));
|
||||
Assert.That(cache.GetByIndex<ItemData>("Sku", "POT-1"), Is.Not.Null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.CurrentDirectory = previous;
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReadmeShowsTheTestedSnippet() =>
|
||||
StartDocs.AssertShowsSnippet(SourcePath(), Path.Combine("..", "..", "src", "GameCult.Caching", "README.md"), wholeBlock: false);
|
||||
|
||||
static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
|
||||
// snippet:begin
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
|
||||
public static class UpdateCall
|
||||
{
|
||||
public static CultUpdateResult<Item> Restock(string path, CultRecordKey key)
|
||||
{
|
||||
var cache = CultCacheMessagePack.Create(path);
|
||||
return cache.Update<Item>(key, item =>
|
||||
{
|
||||
item ??= new Item { Name = "Potion", Category = "Consumable" };
|
||||
item.Value += 10;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
// snippet:end
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// The C# update snippet that docs/start/README.md shows verbatim; it reuses FirstCallTests' Item.
|
||||
public class UpdateCallTests
|
||||
{
|
||||
[Test]
|
||||
public void RestockTwiceReadsTwenty()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"update-call-{Guid.NewGuid():N}.cc");
|
||||
try
|
||||
{
|
||||
var key = new CultRecordKey("potion");
|
||||
Assert.That(UpdateCall.Restock(path, key).Outcome, Is.EqualTo(CultUpdateOutcome.Updated));
|
||||
Assert.That(UpdateCall.Restock(path, key).Outcome, Is.EqualTo(CultUpdateOutcome.Updated));
|
||||
|
||||
using var reader = CultCacheMessagePack.Create(path);
|
||||
Assert.That(reader.Get<Item>(key)!.Value, Is.EqualTo(20));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DocsShowTheTestedSnippet() => StartDocs.AssertShowsSnippet(SourcePath());
|
||||
|
||||
static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,515 +0,0 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
using R3;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// CultCache.Update: read the content version, apply a pure function to a private copy, commit conditionally, re-run on a lost
|
||||
// race (variants:ruling:write-set-content-version-declared-ownership; variants:question:update-apply-copy).
|
||||
public class UpdateTests
|
||||
{
|
||||
private static readonly CultRecordKey Key = new("counter");
|
||||
private static readonly CultDocumentRegistry Registry =
|
||||
CultDocumentRegistry.ForTypes(new[] { typeof(UpdateCounter), typeof(UpdateTally), typeof(UpdateGear) });
|
||||
|
||||
private string _directory = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void CreateDirectory()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-update-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_directory);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void DeleteDirectory()
|
||||
{
|
||||
if (Directory.Exists(_directory))
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
private string PathOf(string name) => Path.Combine(_directory, name);
|
||||
|
||||
private static CultCache Open(string path, bool directory, bool codec = true)
|
||||
{
|
||||
var cache = codec ? new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)) : new CultCache(Registry);
|
||||
cache.AddBackingStore(directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path));
|
||||
return cache;
|
||||
}
|
||||
|
||||
private static void Seed(string path, bool directory, int value)
|
||||
{
|
||||
using var cache = Open(path, directory);
|
||||
Assert.That(cache.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter", Value = value }, new CultRecordHandle<UpdateCounter>(Key))), Is.True);
|
||||
}
|
||||
|
||||
private static void Rival(CultCache cache, int value) =>
|
||||
Assert.That(cache.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter", Value = value }, new CultRecordHandle<UpdateCounter>(Key))), Is.True);
|
||||
|
||||
private static int StoredValue(string path, bool directory)
|
||||
{
|
||||
using var reader = Open(path, directory);
|
||||
return reader.Get<UpdateCounter>(Key)!.Value;
|
||||
}
|
||||
|
||||
private static UpdateCounter Plus(UpdateCounter? counter, int by)
|
||||
{
|
||||
counter ??= new UpdateCounter { Name = "counter" };
|
||||
counter.Value += by;
|
||||
return counter;
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void TwoWritersUpdatingLoseNoIncrement(bool directory)
|
||||
{
|
||||
const int n = 100;
|
||||
var path = PathOf("race.cc");
|
||||
Seed(path, directory, 0);
|
||||
using var a = Open(path, directory);
|
||||
using var b = Open(path, directory);
|
||||
var applied = 0;
|
||||
|
||||
var racers = new[] { a, b }.Select(cache => Task.Run(() =>
|
||||
{
|
||||
var results = new List<CultUpdateOutcome>();
|
||||
for (var i = 0; i < n; i++)
|
||||
results.Add(cache.Update<UpdateCounter>(Key, counter =>
|
||||
{
|
||||
Interlocked.Increment(ref applied);
|
||||
return Plus(counter, 1);
|
||||
}, maxAttempts: 200).Outcome);
|
||||
return results;
|
||||
})).ToArray();
|
||||
Assert.That(Task.WaitAll(racers, TimeSpan.FromMinutes(2)), Is.True, "the racers did not finish");
|
||||
|
||||
Assert.That(racers.SelectMany(racer => racer.Result), Is.All.EqualTo(CultUpdateOutcome.Updated));
|
||||
Assert.That(StoredValue(path, directory), Is.EqualTo(2 * n));
|
||||
Assert.That(applied, Is.GreaterThan(2 * n), "the writers never actually raced");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LostAttemptRerunsApplyOnTheFreshValue()
|
||||
{
|
||||
var path = PathOf("rerun.cc");
|
||||
Seed(path, directory: false, 1);
|
||||
using var cache = Open(path, false);
|
||||
using var rival = Open(path, false);
|
||||
var seen = new List<int>();
|
||||
|
||||
var result = cache.Update<UpdateCounter>(Key, counter =>
|
||||
{
|
||||
seen.Add(counter!.Value);
|
||||
if (seen.Count == 1)
|
||||
Rival(rival, 100);
|
||||
return Plus(counter, 1);
|
||||
});
|
||||
|
||||
Assert.That(seen, Is.EqualTo(new[] { 1, 100 }));
|
||||
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated));
|
||||
Assert.That(result.Document!.Value, Is.EqualTo(101));
|
||||
Assert.That(StoredValue(path, false), Is.EqualTo(101));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExhaustedAttemptsReturnLostRace()
|
||||
{
|
||||
var path = PathOf("lost.cc");
|
||||
Seed(path, false, 1);
|
||||
using var cache = Open(path, false);
|
||||
using var rival = Open(path, false);
|
||||
var calls = 0;
|
||||
|
||||
var result = cache.Update<UpdateCounter>(Key, counter =>
|
||||
{
|
||||
Rival(rival, 100 * ++calls);
|
||||
return Plus(counter, 1);
|
||||
}, maxAttempts: 3);
|
||||
|
||||
Assert.That(calls, Is.EqualTo(3));
|
||||
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace));
|
||||
Assert.That(result.Document!.Value, Is.EqualTo(300));
|
||||
Assert.That(StoredValue(path, false), Is.EqualTo(300), "the store holds the rival's last write");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyGetsAPrivateCopy()
|
||||
{
|
||||
var path = PathOf("copy.cc");
|
||||
Seed(path, false, 1);
|
||||
using var cache = Open(path, false);
|
||||
using var rival = Open(path, false);
|
||||
var published = new List<int>();
|
||||
using var subscription = cache.Watch<UpdateCounter>().Subscribe(change => published.Add(change.Document!.Value));
|
||||
var arguments = new List<int>();
|
||||
var calls = 0;
|
||||
|
||||
var result = cache.Update<UpdateCounter>(Key, counter =>
|
||||
{
|
||||
calls++;
|
||||
arguments.Add(counter!.Value);
|
||||
Assert.That(counter, Is.Not.SameAs(cache.Get<UpdateCounter>(Key)), "apply was handed the held instance");
|
||||
counter.Value += 1000;
|
||||
Assert.That(cache.Get<UpdateCounter>(Key)!.Value, Is.Not.EqualTo(counter.Value), "the change reached the cache before any commit");
|
||||
if (calls == 1)
|
||||
Rival(rival, 5);
|
||||
return counter;
|
||||
});
|
||||
|
||||
Assert.That(arguments, Is.EqualTo(new[] { 1, 5 }), "the second call saw the first call's change");
|
||||
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated));
|
||||
Assert.That(published, Does.Not.Contain(1001), "an uncommitted value was published");
|
||||
Assert.That(published.Count(value => value == 1005), Is.EqualTo(1));
|
||||
Assert.That(StoredValue(path, false), Is.EqualTo(1005));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NullFromApplyDeclines()
|
||||
{
|
||||
var path = PathOf("decline.cc");
|
||||
Seed(path, false, 7);
|
||||
var before = File.ReadAllBytes(path);
|
||||
using var cache = Open(path, false);
|
||||
var changes = 0;
|
||||
using var subscription = cache.Watch<UpdateCounter>().Subscribe(_ => changes++);
|
||||
|
||||
var result = cache.Update<UpdateCounter>(Key, _ => null);
|
||||
|
||||
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Declined));
|
||||
Assert.That(result.Document!.Value, Is.EqualTo(7));
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
|
||||
Assert.That(changes, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void AbsentKeyCreatesOnceUnderRace(bool directory)
|
||||
{
|
||||
var path = PathOf("create.cc");
|
||||
using var a = Open(path, directory);
|
||||
using var b = Open(path, directory);
|
||||
var started = new Barrier(2);
|
||||
|
||||
var racers = new[] { a, b }.Select(cache => Task.Run(() =>
|
||||
{
|
||||
started.SignalAndWait();
|
||||
return cache.Update<UpdateCounter>(Key, counter => Plus(counter, 1)).Outcome;
|
||||
})).ToArray();
|
||||
Assert.That(Task.WaitAll(racers, TimeSpan.FromMinutes(1)), Is.True);
|
||||
|
||||
Assert.That(racers.Select(racer => racer.Result), Is.All.EqualTo(CultUpdateOutcome.Updated));
|
||||
Assert.That(StoredValue(path, directory), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RefusalsAreNotRetried()
|
||||
{
|
||||
var path = PathOf("refuse.cc");
|
||||
Seed(path, false, 1);
|
||||
var before = File.ReadAllBytes(path);
|
||||
var calls = 0;
|
||||
Func<UpdateCounter?, UpdateCounter?> apply = counter => { calls++; return Plus(counter, 1); };
|
||||
|
||||
using (var codecLess = Open(path, false, codec: false))
|
||||
Assert.That(() => codecLess.Update(Key, apply), Throws.InvalidOperationException);
|
||||
using (var cache = Open(path, false))
|
||||
{
|
||||
Assert.That(() => cache.Update(Key, apply, maxAttempts: 0), Throws.TypeOf<ArgumentOutOfRangeException>());
|
||||
Assert.That(() => cache.Update<UpdateCounter>(Key, null!), Throws.TypeOf<ArgumentNullException>());
|
||||
Assert.That(() => cache.Update<UpdateTally>(Key, _ => new UpdateTally { Name = "counter" }), Throws.InvalidOperationException);
|
||||
}
|
||||
|
||||
using (var cache = Open(path, false))
|
||||
{
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(new UpdateGear { Name = "gear", Power = 1 }, new CultRecordHandle<UpdateGear>(new CultRecordKey("gear")));
|
||||
});
|
||||
cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("gear-big"), new CultRecordKey("gear"),
|
||||
new[] { cache.Override<UpdateGear>(nameof(UpdateGear.Name), "gear big"), cache.Override<UpdateGear>(nameof(UpdateGear.Power), 9) }));
|
||||
}
|
||||
|
||||
before = File.ReadAllBytes(path);
|
||||
var gearCalls = 0;
|
||||
using (var cache = Open(path, false))
|
||||
Assert.That(() => cache.Update<UpdateGear>(new CultRecordKey("gear-big"), gear => { gearCalls++; return gear; }), Throws.InvalidOperationException);
|
||||
Assert.That(gearCalls, Is.EqualTo(0), "apply ran for a variant");
|
||||
Assert.That(calls, Is.EqualTo(0), "apply ran for a refused call");
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
|
||||
|
||||
using (var cache = Open(path, false))
|
||||
{
|
||||
cache.UpsertAsync(new UpdateTally { Name = "staged" }).GetAwaiter().GetResult();
|
||||
Assert.That(() => cache.Update(Key, apply), Throws.InvalidOperationException.With.Message.Contains("flush before a conditional commit"));
|
||||
Assert.That(calls, Is.LessThanOrEqualTo(1));
|
||||
}
|
||||
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "a refusal wrote");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void VariantStoreConflictIsALostAttempt()
|
||||
{
|
||||
var path = PathOf("variant.cc");
|
||||
Seed(path, false, 1);
|
||||
using (var seed = Open(path, false))
|
||||
{
|
||||
seed.Commit(batch => batch.Upsert(new UpdateGear { Name = "gear", Power = 1 }, new CultRecordHandle<UpdateGear>(new CultRecordKey("gear"))));
|
||||
seed.Commit(batch => batch.UpsertVariant(new CultRecordKey("gear-big"), new CultRecordKey("gear"),
|
||||
new[] { seed.Override<UpdateGear>(nameof(UpdateGear.Name), "gear big"), seed.Override<UpdateGear>(nameof(UpdateGear.Power), 9) }));
|
||||
}
|
||||
|
||||
using var cache = Open(path, false);
|
||||
using (var rival = Open(path, false))
|
||||
Assert.That(rival.Commit(batch => batch.Upsert(new UpdateTally { Name = "other" }, new CultRecordHandle<UpdateTally>(new CultRecordKey("other")))), Is.True);
|
||||
var calls = 0;
|
||||
|
||||
var result = cache.Update<UpdateCounter>(Key, counter => { calls++; return Plus(counter, 1); });
|
||||
|
||||
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated));
|
||||
Assert.That(calls, Is.EqualTo(2), "the conflict was not a lost attempt that re-ran apply");
|
||||
Assert.That(StoredValue(path, false), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
private static CultPersistedStoreSnapshot ReadSnapshot(string path) =>
|
||||
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
||||
|
||||
// A reload that fails inside a conflict is a read failure, not a lost race: Update lets it propagate and never re-runs apply.
|
||||
// Here the gear a variant is based on has gone foreign in the file, so the reload the conflict owes cannot drop it.
|
||||
[Test]
|
||||
public void ConflictWhoseReloadFailedPropagates()
|
||||
{
|
||||
var path = PathOf("reload-failed.cc");
|
||||
Seed(path, false, 1);
|
||||
using var cache = Open(path, false);
|
||||
cache.Commit(batch => batch.Upsert(new UpdateGear { Name = "gear", Power = 1 }, new CultRecordHandle<UpdateGear>(new CultRecordKey("gear"))));
|
||||
cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("gear-big"), new CultRecordKey("gear"),
|
||||
new[] { cache.Override<UpdateGear>(nameof(UpdateGear.Name), "gear big"), cache.Override<UpdateGear>(nameof(UpdateGear.Power), 9) }));
|
||||
const string foreignId = "sha256:00000000000000000000000000000000000000000000000000000000000050de";
|
||||
var snapshot = ReadSnapshot(path);
|
||||
snapshot.Records.Single(record => record.Key == "gear").SchemaId = foreignId;
|
||||
snapshot.SchemaCatalog = snapshot.SchemaCatalog.Append(new CultSchemaCatalogEntry
|
||||
{
|
||||
SchemaId = foreignId, SchemaName = "tests.update_foreign", SchemaVersion = "tests.update_foreign.v1",
|
||||
ContentHash = "foreign", CanonicalSchemaJson = "{}"
|
||||
}).ToArray();
|
||||
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
||||
var calls = 0;
|
||||
|
||||
var conflict = Assert.Throws<CultWriteConflictException>(() => cache.Update<UpdateCounter>(Key, counter => { calls++; return Plus(counter, 1); }))!;
|
||||
|
||||
Assert.That(conflict.InnerException, Is.Not.Null, "the reload that failed is the cause");
|
||||
Assert.That(calls, Is.EqualTo(1), "a failed reload was retried");
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void RivalWritingAndRestoringTheValueStillRerunsApply(bool directory)
|
||||
{
|
||||
var path = PathOf("aba.cc");
|
||||
Seed(path, directory, 1);
|
||||
using var cache = Open(path, directory);
|
||||
using var rival = Open(path, directory);
|
||||
var seen = new List<int>();
|
||||
|
||||
var result = cache.Update<UpdateCounter>(Key, counter =>
|
||||
{
|
||||
seen.Add(counter!.Value);
|
||||
if (seen.Count == 1)
|
||||
{
|
||||
Rival(rival, 5);
|
||||
Rival(rival, 1);
|
||||
}
|
||||
return Plus(counter, 1);
|
||||
});
|
||||
|
||||
Assert.That(seen, Is.EqualTo(new[] { 1, 1 }), "the content version missed A-B-A");
|
||||
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated));
|
||||
Assert.That(StoredValue(path, directory), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void RivalRemovalIsSeenAsNull(bool directory)
|
||||
{
|
||||
var path = PathOf("removal.cc");
|
||||
Seed(path, directory, 1);
|
||||
using var cache = Open(path, directory);
|
||||
using var rival = Open(path, directory);
|
||||
var seen = new List<int?>();
|
||||
|
||||
var result = cache.Update<UpdateCounter>(Key, counter =>
|
||||
{
|
||||
seen.Add(counter?.Value);
|
||||
if (seen.Count == 1)
|
||||
rival.Commit(batch => batch.Remove(Key));
|
||||
return Plus(counter, 1);
|
||||
});
|
||||
|
||||
Assert.That(seen, Is.EqualTo(new int?[] { 1, null }));
|
||||
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated));
|
||||
Assert.That(StoredValue(path, directory), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExhaustedAttemptsReturnLostRaceInADirectoryStore()
|
||||
{
|
||||
var path = PathOf("lost-dir.cc");
|
||||
Seed(path, true, 1);
|
||||
using var cache = Open(path, true);
|
||||
using var rival = Open(path, true);
|
||||
var calls = 0;
|
||||
|
||||
var result = cache.Update<UpdateCounter>(Key, counter =>
|
||||
{
|
||||
Rival(rival, 100 * ++calls);
|
||||
return Plus(counter, 1);
|
||||
}, maxAttempts: 2);
|
||||
|
||||
Assert.That(calls, Is.EqualTo(2));
|
||||
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace));
|
||||
Assert.That(result.Document!.Value, Is.EqualTo(200));
|
||||
Assert.That(StoredValue(path, true), Is.EqualTo(200));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OneAttemptMeansOneApplyAndNonPositiveThrows()
|
||||
{
|
||||
var path = PathOf("one.cc");
|
||||
Seed(path, false, 1);
|
||||
using var cache = Open(path, false);
|
||||
using var rival = Open(path, false);
|
||||
var calls = 0;
|
||||
|
||||
var result = cache.Update<UpdateCounter>(Key, counter => { calls++; Rival(rival, 50); return Plus(counter, 1); }, maxAttempts: 1);
|
||||
|
||||
Assert.That(calls, Is.EqualTo(1));
|
||||
Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace));
|
||||
Assert.That(() => cache.Update<UpdateCounter>(Key, counter => counter, maxAttempts: -1), Throws.TypeOf<ArgumentOutOfRangeException>());
|
||||
Assert.That(calls, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void UpdatedDocumentIsTheHeldInstanceAndOwnWritesCauseNoRetry(bool directory)
|
||||
{
|
||||
var path = PathOf("own.cc");
|
||||
Seed(path, directory, 1);
|
||||
using var cache = Open(path, directory);
|
||||
cache.UpsertAsync(new UpdateCounter { Name = "counter", Value = 10 }, new CultRecordHandle<UpdateCounter>(Key)).GetAwaiter().GetResult();
|
||||
cache.FlushAsync().GetAwaiter().GetResult();
|
||||
var calls = 0;
|
||||
|
||||
var first = cache.Update<UpdateCounter>(Key, counter => { calls++; return Plus(counter, 1); });
|
||||
var second = cache.Update<UpdateCounter>(Key, counter => { calls++; return Plus(counter, 1); });
|
||||
|
||||
Assert.That(calls, Is.EqualTo(2), "the cache's own writes made a later Update retry");
|
||||
Assert.That(first.Document, Is.Not.Null);
|
||||
Assert.That(second.Document, Is.SameAs(cache.Get<UpdateCounter>(Key)), "Updated returned a copy, not the held instance");
|
||||
Assert.That(StoredValue(path, directory), Is.EqualTo(12));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyRunsOutsideTheCacheGate()
|
||||
{
|
||||
var path = PathOf("gate.cc");
|
||||
Seed(path, false, 1);
|
||||
using var cache = Open(path, false);
|
||||
var reached = false;
|
||||
|
||||
cache.Update<UpdateCounter>(Key, counter =>
|
||||
{
|
||||
reached = Task.Run(() => cache.Get<UpdateCounter>(Key)).Wait(TimeSpan.FromSeconds(10));
|
||||
return Plus(counter, 1);
|
||||
});
|
||||
|
||||
Assert.That(reached, Is.True, "another thread could not read the cache while apply ran");
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void ContendedLedgerStoredValueEqualsUpdatedCount(bool directory)
|
||||
{
|
||||
var path = PathOf("ledger.cc");
|
||||
Seed(path, directory, 0);
|
||||
var caches = Enumerable.Range(0, 3).Select(_ => Open(path, directory)).ToArray();
|
||||
try
|
||||
{
|
||||
var tasks = caches.Select(cache => Task.Run(() =>
|
||||
{
|
||||
var updated = 0;
|
||||
for (var i = 0; i < 25; i++)
|
||||
if (cache.Update<UpdateCounter>(Key, counter => Plus(counter, 1)).Outcome == CultUpdateOutcome.Updated)
|
||||
updated++;
|
||||
return updated;
|
||||
})).ToArray();
|
||||
Assert.That(Task.WaitAll(tasks, TimeSpan.FromMinutes(2)), Is.True);
|
||||
|
||||
Assert.That(StoredValue(path, directory), Is.EqualTo(tasks.Sum(task => task.Result)), "an Updated outcome was lost or a LostRace wrote");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var cache in caches)
|
||||
cache.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InMemoryCacheUpdatesUnderThreadRace()
|
||||
{
|
||||
using var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry));
|
||||
cache.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter" }, new CultRecordHandle<UpdateCounter>(Key)));
|
||||
|
||||
var threads = Enumerable.Range(0, 2).Select(_ => Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < 100; i++)
|
||||
Assert.That(cache.Update<UpdateCounter>(Key, counter => Plus(counter, 1), maxAttempts: 200).Outcome, Is.EqualTo(CultUpdateOutcome.Updated));
|
||||
})).ToArray();
|
||||
Assert.That(Task.WaitAll(threads, TimeSpan.FromMinutes(1)), Is.True);
|
||||
|
||||
Assert.That(cache.Get<UpdateCounter>(Key)!.Value, Is.EqualTo(200));
|
||||
}
|
||||
|
||||
[CultDocument("tests.update_counter", "tests.update_counter.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class UpdateCounter
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
public int Value;
|
||||
}
|
||||
|
||||
[CultDocument("tests.update_tally", "tests.update_tally.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class UpdateTally
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
}
|
||||
|
||||
[CultDocument("tests.update_gear", "tests.update_gear.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class UpdateGear
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
public int Power;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"stryker-config": {
|
||||
"project": "GameCult.Caching.csproj",
|
||||
"mutate": [
|
||||
"**/src/GameCult.Caching/**/*.cs"
|
||||
],
|
||||
"mutation-level": "Standard",
|
||||
"reporters": [ "progress", "html", "json" ],
|
||||
"thresholds": { "high": 80, "low": 60, "break": 0 }
|
||||
}
|
||||
}
|
||||
|
|
@ -341,7 +341,7 @@ namespace GameCult.Networking.Tests
|
|||
{
|
||||
var path = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.rs-written.json");
|
||||
if (!File.Exists(path))
|
||||
Assert.Inconclusive($"{path} is missing - run cargo test --locked -p cultnet-rs --test selection with CULTNET_WRITE_VECTORS=1 first.");
|
||||
Assert.Inconclusive($"{path} is missing - run cargo test -p cultnet-rs --test selection with CULTNET_WRITE_VECTORS=1 first.");
|
||||
|
||||
var (registry, rows, _) = BuildFixture();
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@ probe() {
|
|||
node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json
|
||||
node --test dist-test/test/old-reader-v3.test.js ;;
|
||||
python) PYTHONPATH=packages/cultcache-py/src python3 packages/cultcache-py/tests/test_old_reader_v3.py -v ;;
|
||||
# The staged tree is the frozen cultcache-rs-v0.3.1 revision, which has no committed Cargo.lock: --locked cannot work here.
|
||||
rust) cd packages/cultcache-rs && cargo test --test old_reader_v3 -- --nocapture ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
|
|
|||
Loading…
Reference in a new issue