Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f5364e4dc | |||
| f4fe424d41 |
6 changed files with 218 additions and 5 deletions
|
|
@ -1,6 +1,8 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use cultcache_rs::CultCache;
|
||||
use cultcache_rs::CultSlots;
|
||||
use cultcache_rs::CultUnion;
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use cultcache_rs::SingleFileMessagePackBackingStore;
|
||||
use serde_json::json;
|
||||
|
|
@ -39,6 +41,100 @@ struct CultCacheInteropDeck {
|
|||
marks: Vec<CultCacheInteropMark>,
|
||||
}
|
||||
|
||||
// A union field and nested bytes: the shapes a Bifrost-style store holds beyond plain strings.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, CultUnion)]
|
||||
enum InteropArm {
|
||||
#[cultcache(key = 0)]
|
||||
Text(InteropTextArm),
|
||||
#[cultcache(key = 1)]
|
||||
Pack(InteropPackArm),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, CultSlots)]
|
||||
struct InteropTextArm {
|
||||
#[cultcache(key = 0)]
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, CultSlots)]
|
||||
struct InteropPackArm {
|
||||
#[cultcache(key = 0)]
|
||||
label: String,
|
||||
#[cultcache(key = 1)]
|
||||
chunks: Vec<serde_bytes::ByteBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "cultcache.interop-shape", schema = "CultCacheInteropShape")]
|
||||
struct CultCacheInteropShape {
|
||||
#[cultcache(key = 0)]
|
||||
document_id: String,
|
||||
#[cultcache(key = 1)]
|
||||
arm: InteropArm,
|
||||
#[cultcache(key = 2)]
|
||||
raw: serde_bytes::ByteBuf,
|
||||
}
|
||||
|
||||
fn shapes() -> Vec<CultCacheInteropShape> {
|
||||
vec![
|
||||
CultCacheInteropShape {
|
||||
document_id: "shape:text".into(),
|
||||
arm: InteropArm::Text(InteropTextArm { text: "union arm 0".into() }),
|
||||
raw: (0..=255u8).collect::<Vec<_>>().into(),
|
||||
},
|
||||
CultCacheInteropShape {
|
||||
document_id: "shape:pack".into(),
|
||||
arm: InteropArm::Pack(InteropPackArm {
|
||||
label: "union arm 1".into(),
|
||||
chunks: vec![vec![0xc0, 0xc1, 0x00].into(), vec![].into(), vec![0x92, 0x01, 0x02].into()],
|
||||
}),
|
||||
raw: vec![0xde, 0xad, 0xbe, 0xef].into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn write_shapes(file: &str) -> Result<()> {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<CultCacheInteropShape>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?;
|
||||
cache.pull_all_backing_stores()?;
|
||||
for shape in shapes() {
|
||||
cache.put(&shape.document_id, &shape)?;
|
||||
}
|
||||
print_shapes(shapes())
|
||||
}
|
||||
|
||||
/// Opens the store through the read-only snapshot door: no lock file, no staging file, no rewrite.
|
||||
fn read_shapes(file: &str) -> Result<()> {
|
||||
let envelopes = SingleFileMessagePackBackingStore::new(file).pull_all_read_only_snapshot()?;
|
||||
let mut found = Vec::new();
|
||||
for envelope in envelopes.iter().filter(|envelope| envelope.r#type == CultCacheInteropShape::TYPE) {
|
||||
found.push(cultcache_rs::decode_payload::<CultCacheInteropShape>(&envelope.payload)?);
|
||||
}
|
||||
print_shapes(found)
|
||||
}
|
||||
|
||||
fn print_shapes(mut found: Vec<CultCacheInteropShape>) -> Result<()> {
|
||||
let hex = |bytes: &[u8]| bytes.iter().map(|byte| format!("{byte:02X}")).collect::<String>();
|
||||
found.sort_by(|a, b| a.document_id.cmp(&b.document_id));
|
||||
let rows: Vec<_> = found
|
||||
.iter()
|
||||
.map(|shape| {
|
||||
let arm = match &shape.arm {
|
||||
InteropArm::Text(text) => json!({ "kind": "text", "text": text.text }),
|
||||
InteropArm::Pack(pack) => json!({
|
||||
"kind": "pack",
|
||||
"label": pack.label,
|
||||
"chunks": pack.chunks.iter().map(|chunk| hex(chunk)).collect::<Vec<_>>(),
|
||||
}),
|
||||
};
|
||||
json!({ "documentId": shape.document_id, "arm": arm, "raw": hex(&shape.raw) })
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::to_string(&rows)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_deck(file: &str) -> Result<()> {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<CultCacheInteropDeck>()?;
|
||||
|
|
@ -70,6 +166,8 @@ fn main() -> Result<()> {
|
|||
"write" => write_note(file, require_arg(&options, "runtime-id")?)?,
|
||||
"read" => read_note(file)?,
|
||||
"read-deck" => read_deck(file)?,
|
||||
"write-shapes" => write_shapes(file)?,
|
||||
"read-shapes" => read_shapes(file)?,
|
||||
"hold-lock" => hold_lock(file, require_arg(&options, "ms")?.parse()?)?,
|
||||
_ => return Err(anyhow!("unknown mode {mode}")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,8 +33,7 @@ const cultcacheRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultcache-r
|
|||
? resolve(cultLibRoot, "packages", "cultcache-rs")
|
||||
: resolve(cultCacheTsRoot, "..", "cultcache-rs");
|
||||
const rustInteropBinary = resolve(
|
||||
cultcacheRsRoot,
|
||||
"target",
|
||||
process.env.CARGO_TARGET_DIR ?? resolve(cultcacheRsRoot, "target"),
|
||||
"debug",
|
||||
"examples",
|
||||
process.platform === "win32" ? "cultcache_interop.exe" : "cultcache_interop",
|
||||
|
|
@ -1273,6 +1272,48 @@ test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Pyth
|
|||
}
|
||||
});
|
||||
|
||||
// A Bifrost-shaped store (a union field, nested bytes) written by one of C# and Rust is opened by the other: C# reads through
|
||||
// its store, Rust through the read-only snapshot door, which takes no lock and writes nothing. Leftover staging files from an
|
||||
// interrupted write of either runtime sit beside the store; a read neither deletes them nor lets them change what it reads, and
|
||||
// the store file's bytes are the same after every read.
|
||||
test("A store written by C# or Rust with a union and nested bytes is read back equal by the other, read-only, past leftover staging files", async () => {
|
||||
await buildInteropPeers();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-shapes-"));
|
||||
const rustRead = (file: string) => runJsonCommand("rust-read-shapes", rustInteropBinary, ["read-shapes", "--file", file], cultcacheRsRoot);
|
||||
const csharpRead = (file: string) => runJsonCommand("csharp-read-shapes", dotnetCommand, [csharpInteropDll, "read-shapes", "--file", file], cultLibRoot);
|
||||
const peers = [
|
||||
{
|
||||
writer: "csharp",
|
||||
write: (file: string) => runJsonCommand("csharp-write-shapes", dotnetCommand, [csharpInteropDll, "write-shapes", "--file", file], cultLibRoot),
|
||||
read: rustRead,
|
||||
},
|
||||
{
|
||||
writer: "rust",
|
||||
write: (file: string) => runJsonCommand("rust-write-shapes", rustInteropBinary, ["write-shapes", "--file", file], cultcacheRsRoot),
|
||||
read: csharpRead,
|
||||
},
|
||||
];
|
||||
for (const { writer, write, read } of peers) {
|
||||
const file = join(tempDir, `${writer}.cc`);
|
||||
const written = await write(file);
|
||||
assert.equal(written.length, 2);
|
||||
// The leftovers an interrupted write leaves: C# stages as `.<name>.<guid N>.tmp`, Rust as `<name>.<guid>.tmp`; both hold partial bytes.
|
||||
const leftovers = [
|
||||
join(tempDir, `.${writer}.cc.0123456789abcdef0123456789abcdef.tmp`),
|
||||
join(tempDir, `${writer}.cc.00000000-0000-4000-8000-000000000000.tmp`),
|
||||
];
|
||||
for (const leftover of leftovers) {
|
||||
await writeFile(leftover, Buffer.from([0x93, 0xb2]));
|
||||
}
|
||||
const before = await readFile(file);
|
||||
assert.deepEqual(await read(file), written, `${writer}'s store did not read back equal`);
|
||||
assert.deepEqual(await readFile(file), before, `reading ${writer}'s store changed its bytes`);
|
||||
for (const leftover of leftovers) {
|
||||
assert.ok(await exists(leftover), `reading ${writer}'s store removed ${leftover}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Records and catalog entries a runtime does not own survive its writes. C# writes a store and each other runtime writes its own
|
||||
// note into it: C# reads its note back, and its record and catalog entry are as C# wrote them. C# then writes again, and the other
|
||||
// runtime's record and catalog entry are as that runtime wrote them. Identity is compared on the decoded MessagePack values, every
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="..\..\packages\cultmath\src\CultMath\CultMath.csproj" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.7" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
|
||||
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
|
||||
<InternalsVisibleTo Include="GameCult.Caching.GeneratorConsumer" />
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.7" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="..\GameCult.Caching.MessagePack\buildTransitive\GameCult.Caching.MessagePack.targets" />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.7" />
|
||||
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
<PackageReference Include="R3" Version="1.3.0" />
|
||||
<PackageReference Include="LiteNetLib" Version="2.1.2" />
|
||||
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ static async Task<int> ProgramMainAsync(string[] args)
|
|||
case "edit-in-place":
|
||||
await EditInPlaceAsync(file, RequireArg(options, "kind"));
|
||||
return 0;
|
||||
case "write-shapes":
|
||||
await WriteShapesAsync(file);
|
||||
return 0;
|
||||
case "read-shapes":
|
||||
await ReadShapesAsync(file);
|
||||
return 0;
|
||||
case "hold-lock":
|
||||
HoldLock(file, int.Parse(RequireArg(options, "ms"), CultureInfo.InvariantCulture));
|
||||
return 0;
|
||||
|
|
@ -96,6 +102,49 @@ static async Task WriteAsync(string file, string runtimeId)
|
|||
WriteJsonLine(note);
|
||||
}
|
||||
|
||||
// Records a reader of a Bifrost-shaped store must open: a union field (two arms) and nested bytes (a bin member, a bin list).
|
||||
static async Task WriteShapesAsync(string file)
|
||||
{
|
||||
var cache = BuildCache(file);
|
||||
await cache.PullAllBackingStoresAsync();
|
||||
foreach (var shape in InteropShapes())
|
||||
await cache.AddAsync(shape, new CultRecordHandle<CultCacheInteropShape>(new CultRecordKey(shape.DocumentId)));
|
||||
cache.FlushAllBackingStores();
|
||||
WriteShapesJsonLine(InteropShapes());
|
||||
}
|
||||
|
||||
static async Task ReadShapesAsync(string file)
|
||||
{
|
||||
var cache = BuildCache(file);
|
||||
await cache.PullAllBackingStoresAsync();
|
||||
WriteShapesJsonLine(cache.AllEntries.OfType<CultCacheInteropShape>());
|
||||
}
|
||||
|
||||
static CultCacheInteropShape[] InteropShapes() =>
|
||||
[
|
||||
new() { DocumentId = "shape:text", Arm = new InteropTextArm { Text = "union arm 0" }, Raw = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray() },
|
||||
new()
|
||||
{
|
||||
DocumentId = "shape:pack",
|
||||
Arm = new InteropPackArm { Label = "union arm 1", Chunks = [new byte[] { 0xc0, 0xc1, 0x00 }, [], [0x92, 0x01, 0x02]] },
|
||||
Raw = [0xde, 0xad, 0xbe, 0xef]
|
||||
}
|
||||
];
|
||||
|
||||
// One line, ordered by document id, in the form the Rust peer prints.
|
||||
static void WriteShapesJsonLine(IEnumerable<CultCacheInteropShape> shapes) =>
|
||||
Console.Out.WriteLine(JsonSerializer.Serialize(shapes.OrderBy(shape => shape.DocumentId, StringComparer.Ordinal).Select(shape => new
|
||||
{
|
||||
documentId = shape.DocumentId,
|
||||
arm = shape.Arm switch
|
||||
{
|
||||
InteropTextArm text => (object)new { kind = "text", text = text.Text },
|
||||
InteropPackArm pack => new { kind = "pack", label = pack.Label, chunks = pack.Chunks.Select(Convert.ToHexString).ToArray() },
|
||||
_ => throw new InvalidOperationException("Unknown arm.")
|
||||
},
|
||||
raw = Convert.ToHexString(shape.Raw)
|
||||
})));
|
||||
|
||||
static async Task ReadAsync(string file)
|
||||
{
|
||||
var cache = BuildCache(file);
|
||||
|
|
@ -328,3 +377,28 @@ public sealed class CultCacheInteropMark
|
|||
[Key(0)] public string Label { get; set; } = string.Empty;
|
||||
[Key(1)] [CultElementId] public string Id { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[CultDocument("cultcache.interop-shape", "cultcache.interop_shape.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class CultCacheInteropShape
|
||||
{
|
||||
[Key(0)] [CultName] public string DocumentId { get; set; } = string.Empty;
|
||||
[Key(1)] public InteropArm Arm { get; set; } = new InteropTextArm();
|
||||
[Key(2)] public byte[] Raw { get; set; } = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
[Union(0, typeof(InteropTextArm)), Union(1, typeof(InteropPackArm))]
|
||||
public abstract class InteropArm { }
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class InteropTextArm : InteropArm
|
||||
{
|
||||
[Key(0)] public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class InteropPackArm : InteropArm
|
||||
{
|
||||
[Key(0)] public string Label { get; set; } = string.Empty;
|
||||
[Key(1)] public List<byte[]> Chunks { get; set; } = new();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue