How to Add Commit and Rollback to a C# Object Store

A user changes a price from 100 to 200, then decides the edit is wrong. The screen can put the old number back, but what happened to the stored data? An importer has the same question on a larger scale about whether a rejected batch should leave a partially accepted catalogue behind. These are persistence decisions, not merely user-interface decisions.

Sphere10 Framework's file-backed ObjectSpace uses transactional pages and explicit commit and rollback. To make the boundary observable, we will save and commit a price of 100, save a pending price of 200, roll it back, then accept 150 and reopen the file. The example deliberately checks fresh tracked state after rollback and fresh file-backed state after disposal. Neither an unchanged label nor an old in-memory object is accepted as proof of a durable transaction.

Committed records and a separate pending change in transactional storage.
Committed records and a separate pending change in transactional storage.

Contents

Prepare the Example

Use the .NET 10 SDK and Sphere10 Framework 3.1.3. In a new console project, add the core package:


dotnet new console --framework net10.0 --name ObjectSpaceTransactions
cd ObjectSpaceTransactions
dotnet add package Sphere10.Framework --version 3.1.3

Replace the generated source with the named files below, or download the complete example at the end. The package supplies the storage, serialization and collection mechanisms, while the small model and workflow belong to this application. Use a new writable output directory. The sample refuses to overwrite an existing store rather than silently clearing earlier data.

Keep the Transaction Example Focused

Use Product.cs for the application model:


namespace ArticleSample;

public sealed class Product {
	public long Id { get; set; }
	public string Sku { get; set; }
	public string Name { get; set; }
	public int Category { get; set; }
	public decimal Price { get; set; }
}

For this exercise, the dimension has only an identifier index. The price is ordinary persisted data, and the application retrieves the product by its known identifier. This keeps the transaction demonstration independent of indexed-key editing. Additional query indexes belong in a separately verified schema, not in every sample by habit.

Save the following factory as CatalogueStore.cs:


using System.IO;
using Sphere10.Framework;
using Sphere10.Framework.ObjectSpaces;

namespace ArticleSample;

public static class CatalogueStore {
	public static FileObjectSpace Open(string directory) {
		Directory.CreateDirectory(directory);
		Directory.CreateDirectory(Path.Combine(directory, "pending-pages"));
		var builder = new ObjectSpaceBuilder()
			.UseFile(Path.Combine(directory, "catalogue.s10"))
			.KeepUncommittedPagesAt(Path.Combine(directory, "pending-pages"))
			.WithClusterSize(4096)
			.WithPageSize(64 * 1024)
			.WithMaxMemory(4 * 1024 * 1024)
			.WithIndexBTreeOrder(64)
			.WithEndianness(Endianness.LittleEndian)
			.AutoLoad();
		builder.AddDimension<Product>(ignoreAnnotations: true)
			.WithIdentifier(item => item.Id)
			.OptimizeAssumingAverageItemSize(256)
			.Done();
		return (FileObjectSpace)builder.Build();
	}
}

The factory creates both required directories and supplies the same byte order, cluster geometry and index order whenever it opens this sample's file. Transaction page files are part of the backend's working storage. They do not replace an application backup or establish recovery from every filesystem failure. Keep the file and its supported page-recovery configuration under application ownership.

Reject One Edit and Accept Another

Save this as Program.cs:


using System;
using System.IO;
using Sphere10.Framework.ObjectSpaces;

namespace ArticleSample;

public static class Program {
	public static void Main(string[] args) {
		var directory = args.Length == 0 ? Path.Combine(Environment.CurrentDirectory, "sample-data") : args[0];
		if (File.Exists(Path.Combine(directory, "catalogue.s10")))
			throw new InvalidOperationException("Choose a fresh output directory for this transaction example.");
		using (var space = CatalogueStore.Open(directory)) {
			space.Save(new Product { Id = 101, Sku = "KB-101", Name = "Keyboard", Category = 7, Price = 100m });
			space.Commit();
			var draft = space.Get((Product item) => item.Id, 101L);
			draft.Price = 200m;
			space.Save(draft);
			space.Rollback();
			// Rollback reloads the space. The earlier draft is no longer a valid tracked edit.
			var restored = space.Get((Product item) => item.Id, 101L);
			if (restored.Price != 100m)
				throw new InvalidOperationException("Rollback did not restore the committed price.");
			Console.WriteLine($"Price after rollback: {restored.Price:0}");
			restored.Price = 150m;
			space.Save(restored);
			space.Commit();
		}
		using (var reopened = CatalogueStore.Open(directory)) {
			var accepted = reopened.Get((Product item) => item.Id, 101L);
			if (accepted.Price != 150m)
				throw new InvalidOperationException("Accepted price was not durable after reopen.");
			Console.WriteLine($"Price after commit and reopen: {accepted.Price:0}");
			Console.WriteLine("Transaction checks: passed");
		}
	}
}

The first Commit() establishes the accepted price of 100. The subsequent Save of 200 changes pending storage, but the example does not commit it. Rollback() unloads and reloads the ObjectSpace. The original draft reference must therefore be discarded as an editing handle, even if its C# object still exists in the caller's scope.

The sample retrieves the product again after rollback. That fresh retrieval must show 100. It then changes the fresh instance to 150, saves it and commits. Finally, it disposes that ObjectSpace and opens a new one to verify the accepted price from the file.

Run the Transaction Checks


dotnet run -c Release -- ./fresh-transaction-demo

The expected output is:


Price after rollback: 100
Price after commit and reopen: 150
Transaction checks: passed

Each line corresponds to a different assertion. The first checks rollback's restored state. The second checks a commit through reopen. The final line is printed only after both values match. A useful pattern for tests around an import or editor is to make the starting accepted state explicit, exercise a rejected path, then prove that a later accepted operation still works.

⚠️ Rollback invalidates previously tracked objects and derived edit/query snapshots. Refresh the application's editing rows before allowing another save. Do not hold an old row and assume it still identifies the same live object after rollback, reopen or storage-slot reuse.

Put the Decision in the Application

The storage backend does not know whether a quotation was approved, an import passed business validation or a user clicked Cancel. Your application decides when to call commit and rollback. Validate the whole intended operation before publishing it, and make the accepted boundary clear in the user flow. Saving a model member or flushing a buffer is not the business approval itself.

Use the synchronous Commit() and Rollback() APIs shown here. The selected implementation does not provide working asynchronous transaction counterparts merely because async-named methods exist. Current ObjectSpace sessions also retain thread-affine resources, so create, operate and dispose of the session on its owner thread, and do not carry an access scope across await. A UI can queue operations to a dedicated session worker rather than use an unrelated thread-pool task for every button click.

This tutorial demonstrates normal rollback and reopen behavior. It does not simulate power loss, a failed disk or a distributed transaction across multiple external systems. Those requirements need their own recovery design and tests.

Continue Building

The complete sample project includes the project file, source, expected console output and MIT license notice. Code excerpts on this page are supplied under that same license. Keep the package version with your application and retain the sample's observable checks when adapting it because compilation alone does not prove persistence or correct query results.

sample.zip
Download the complete worked example and build instructions (ZIP).

Sphere10 Framework lets you compose this capability with the rest of your application rather than adopt an unrelated application shell. Start with the working example, change one schema or workload decision at a time, and verify the resulting behavior with your own records.

The embedded catalogue tutorial adds useful lookup indexes. Self-describing storage is the next step when supported construction metadata should travel with the stored bytes.

Useful references:

Continue the Series

How to Build an Embedded Object Database in C#

How to Build a Self-Describing Object Store