Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Libby
e3bce664e0 CultCache interop: C# and Rust write a union and nested bytes, the other reads them read-only
All checks were successful
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Successful in 29m46s
Extends the existing cross-runtime harness (cultcache-ts interop test, C# and Rust interop peers) with
write-shapes/read-shapes: a union field with two arms and bin members. Rust reads through the read-only
snapshot door; leftover staging files of both runtimes sit beside the store and must survive the read,
and the store bytes must not change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 11:01:13 +00:00
3 changed files with 215 additions and 2 deletions

View file

@ -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}")),
}

View file

@ -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

View file

@ -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();
}