I built a Zero-Allocation C# Knowledge Graph (because JVM graphs are too bloated)

If you are building AI agents, you eventually hit the “Memory Wall”.

Your agent doesn’t just need semantic text chunks (Vector Search) or structured tables (SQL). It often needs to trace relationships. For example: Find all suppliers connected to this failing part, or Find the common connection between User_A and User_B.

To solve this, the industry tells you to spin up a massive Java-based Graph Database container.

I’ve been writing C# and SQL for decades, and I despise unnecessary bloat. I didn’t want to run a 2GB JVM container locally just to traverse a few hundred thousand relationships.

So, I built Glacier.Graph.

It is a zero-dependency, bare-metal C# Knowledge Graph. It completely bypasses the .NET Garbage Collector and can persist 230,000 edges to disk in 30 milliseconds.

Here is how I architected the engine to achieve memory-bandwidth speeds.

The Problem with “Object-Oriented” Graphs

When most developers try to build a graph in memory, they immediately reach for Object-Oriented Programming:

public class Node 
{
    public string Id { get; set; }
    public List<Edge> Edges { get; set; }
}

public class Edge 
{
    public Node Target { get; set; }
    public string RelationType { get; set; }
}

If you have 100,000 nodes and 500,000 relationships, the .NET Garbage Collector now has to track 600,000 object headers scattered randomly across the heap. Traversing this graph means chasing pointer references through RAM, missing the CPU cache every single time, and dealing with brutal GC pauses.

The Fix: The “Forward Star” Representation

Instead of objects, Glacier.Graph uses the Forward Star (or Compressed Sparse Row) technique.

All edges are stored in flat, primitive integer arrays. When you add a relationship, the engine doesn’t allocate an object; it just increments an index and writes to int[] arrays.

// The core of the Graph Store
private int[] _head;      // Starting edge index for a node
private int[] _to;        // Target node ID
private int[] _relation;  // Type of relation (e.g. "KNOWS")
private int[] _next;      // Index of the next edge for this node

When you traverse the graph, you aren’t doing heap allocations. You are just doing sequential array index lookups. The CPU cache prefetcher loves this, resulting in near-instantaneous traversal speeds.

The Benchmark: Traversing 150,000 Nodes

I generated a complex synthetic graph with 150,000 nodes and 233,000 edges (including chains, branches, and back-references).

I then ran a Breadth-First Search (BFS) to find the shortest path between User_1 and User_99999.

Here is the raw console output:

==========================================
 Glacier.Graph | High-Performance Graph DB
==========================================

[1] Initializing Graph Engine and generating data...
    Total Nodes: 150,001 | Total Edges: 233,333

[3] Executing BFS Shortest Path (User_1 -> User_99999)...
    Path found in 5.3066 ms!
    Hops: 25
    Route: User_1 -> ... (24 intermediate nodes) ... -> User_99999

[4] Executing 4-Hop Neighborhood Search around User_1...
    Neighborhood scanned in 0.4416 ms!
    Discovered 11 connected entities within 4 hops.

Finding a 25-hop path through 150,000 nodes took 5.3 milliseconds. Mapping an entire 4-hop neighborhood took 0.44 milliseconds.

You can’t even complete the HTTP handshake to a standard database in the time it takes this engine to traverse the entire network.

Blazing Fast Persistence

Because the graph is just a collection of primitive int[] arrays, saving the graph to disk is incredibly fast. We don’t use JSON or heavy serialization.

Using MemoryMarshal.AsBytes(), the engine grabs the raw bytes of the arrays directly out of RAM and blasts them to the SSD.

[2] Saving raw memory arrays to disk...
    Saved 27.46 MB to 'graph_database.bin' in 30 ms.

[3] Destroying graph in memory and reloading from disk...
    Graph revived from disk in 41 ms!

30 milliseconds to save. 41 milliseconds to revive.

Built for AI Agents

Like my other libraries, I didn’t want to build a bloated REST API.

Glacier.Graph includes a built-in Model Context Protocol (MCP) server over standard I/O. You can point your AI agents (via AgentDevKit, Claude Desktop, or Cursor) directly at the .dll.

The AI instantly gets JSON-RPC access to add_node, add_edge, find_shortest_path, and find_neighborhood. It allows LLMs to autonomously traverse a high-speed Knowledge Graph without any Python dependencies.

Try it out

If you are tired of massive containerized databases and want bare-metal C# performance for your relational AI data, give it a shot.

GitHub: ian-cowley/Glacier.Graph

Let me know what your traversal times look like!

Polonyalı General: Türkiye, NATO'da kilit ortak, Bayraktarlar Kiev'in savunmasında kritik rol oynadı

Polonya Genelkurmay Başkan Yardımcısı Tuğgeneral Bogdan Pidanty, Türkiye’nin Bayraktar İHA’larının yanı sıra roket, füze topçuluğu ve elektronik harp alanlarında da önemli deneyime sahip olduğunu belirterek, Türkiye’yi NATO’da kilit bir ortak olarak nitelendirdi. Ayrıca Polonya’nın Ukrayna’ya 4 milyar avro değerinde askeri teçhizat sağladığını, GSYİH’nın yüzde 5’ine ulaşan yardımlarla dünyada birinci sırada olduğunu vurguladı.

Java Concurrency: From Threads to Thread Safety — Part 1

I have been spending some time to dig deeper into Java concurrency – threads, synchronization, race conditions.
Most tutorials usually show you how to start a Thread, print "Hello World" which is not how any of this works in production.

This post covers my experiments, traps that I fell into, and what finally clicked – the non-“Hello World” way.

Processes vs. Threads

You can think of a Process as a factory. It has its own walls and its own resources. When you boot up a Java application, the JVM itself is the process.

A Thread is a worker inside your factory. The JVM automatically creates the main thread when your app starts. Every other thread you spawn is born from that main thread. Because all threads share the exact same workspace (the Java Heap memory), they can communicate instantly.

This shared memory is the root cause that makes multithreading dangerous. If one thread reads a value while another thread is halfway through updating it, the data gets corrupted. They don’t even need to run at the exact same instant — one thread just needs to get in between another’s read and write.

What threads share vs What they own

Shared between all threads in a process:

  • Heap memory — all your new SomeObject() calls
  • Static variables — one copy, every thread sees it
  • Class bytecode — the compiled .class files

Private to each thread:

  • Its own stack — local variables, method parameters, return addresses
  • Program counter — which line is this thread currently executing

Creating a thread: The Two Ways

If you want to hire a background worker in Java, there are two ways to do it.

Way 1: Extend the Thread Class

class MyWorker extends Thread {
    @Override
    public void run() {
        System.out.println("Running on: " + Thread.currentThread().getName());
    }
}
new MyWorker().start();

Way 2: Implement Runnable

Runnable is an interface with a single run() method.

Runnable task = () -> {
    System.out.println("Running on: " + Thread.currentThread().getName());
};
new Thread(task).start();

Note: Implementing Runnable keeps your class free to extend something else. In real codebases, always prefer Runnable.

Pitfall #1

Observe the below code:

new Thread(task).run();    // Looks right. Completely wrong.
new Thread(task).start();  // What you actually want.

I ran this to see what happens:

Runnable task = () -> {
    System.out.println("Running on: " + Thread.currentThread().getName());
};

System.out.println("--- calling run() ---");
new Thread(task).run();

System.out.println("--- calling start() ---");
new Thread(task).start();

Output:

    --- calling run() ---
    Running on: main
    --- calling start() ---
    Running on: Thread-1

run() is just a regular method call. No new thread is born. The main thread itself jumps into run() and executes it. You never left main. Nothing concurrent happened.

start() actually tells the JVM — spin up a new thread, give it a stack, and let it run. That’s when you see Thread-1 in the output.

Calling run() instead of start() is the worst kind of bug because it throws no errors, the code executes, but your application stays single-threaded.

The Static Variable and join()

Runnable has a fundamental flaw – run() method returns void. It behaves like fire and forget.

The hack was to use a shared static variable, whenever a value needs to be returned.

static int result = 0;

public static void main(String[] args) {
    Runnable task = () -> { result = 10 + 10; };
    new Thread(task).start();

    System.out.println(result); // What does this print?
}

If you run this, it will almost always print 0.
Because the main thread is fast. It starts the background worker, and then immediately races to the next line and prints result before the background worker even computes.

To fix this, we have to force the main thread to wait using .join().

    Thread t1 = new Thread(task);
    t1.start();
    t1.join(); // Tells the main thread: Stop here until t1 is completely dead.
    System.out.println(result); // Now prints 20

Pitfall #2: The Race Condition

The static variable combined with .join() works for one thread, but it falls apart in production code.
If you have two threads writing to that shared result variable at the same time, their data overwrites each other unpredictably. The output will change from run to run making it difficult to debug.

Callable & FutureTask

Static variable hack is so unpredictable, Java introduced Callable.

It is exactly like Runnable, but it returns a value and can throw exceptions.

But there is a catch: Thread doesn’t accept Callable directly. The compiler will throw an error if you try new Thread(myCallable).

So Java has FutureTask — a wrapper that accepts a Callable and is itself Runnable, so Thread can take it:

    Callable<String> task = () -> {
        return "Result from: " + Thread.currentThread().getName();
    };

    FutureTask<String> futureTask = new FutureTask<>(task);
    new Thread(futureTask).start();

    System.out.println(futureTask.get()); // blocks until result is ready

ExecutorService & Future

Above code works fine. But we are creating a new Thread for every task. In real production case, this can easily spin up thousands of threads and result in OutOfMemoryError.

To put a check on this, a manager for thread creation is needed: an ExecutorService.

When you submit a Callable to a thread pool, the manager gives you back a Future.

A Future is basically a promise — the worker is on it, and the result will show up here when it’s done. Main thread doesn’t freeze, it just calls future.get() when it actually needs the value.

// 1. Initialize the Manager (A pool of 10 workers)
ExecutorService executorService = Executors.newFixedThreadPool(10);

// 2. Submit the task
Future<String> future = executorService.submit(myCallable);

// 3. Check the result
System.out.println(future.get());

executorService.shutdown();

Pitfall #3

The .get() method is blocking. When your main thread hits future.get(), it pauses and waits for the background worker to populate the box.

If that background worker gets stuck in an infinite loop, the main thread waits forever. Your application freezes. In production, never call .get() without a timeout.

try {
    System.out.println(future.get(3, TimeUnit.SECONDS));
} catch (TimeoutException e) {

    System.out.println("Task took too long!");
    future.cancel(true); // <--- Kill the zombie worker
}

Note: cancel(true). If you give up waiting, tell the worker to stop. Otherwise, it stays alive in the background keeping CPU running forever as a “zombie worker”.

Shared State

Even with thread pools and futures, what happens when all threads try to access the exact same data?

Imagine a simple Bank Account:

public class BankAccount {
    private int balance = 0;

    public void deposit() {
        balance ++; 
    }

    public int getBalance() {
        return balance;
    }
}

Let’s use our ExecutorService to submit exactly 10000 tasks. Every single task will simply call deposit() on the exact same bank account.

ExecutorService service = Executors.newFixedThreadPool(10);
BankAccount bankAccount = new BankAccount();

// Submit 10,000 deposit tasks to the pool
for (int i = 0; i < 10000; i++) {
    service.submit(() -> bankAccount.deposit());
}

// wait for the workers to finish
service.shutdown(); 
service.awaitTermination(5, TimeUnit.SECONDS);

System.out.println("Final balance: " + bankAccount.getBalance());

10000 deposits should equal a final balance of 10000. But if you run this code, you will get 9998. Or 9991. Or 9997.

Pitfall #4

For us, balance++ is one step. But to a CPU, it is three:

  1. Read the current balance.
  2. Add 1 to it.
  3. Write the new value back.

For example: If Thread A reads the balance at 5000, and Thread B reads it before A writes back, they both see 5000. Both add 1, both write 5001. Two deposits, one update lost.

Fixing the Race Condition

First thing to try is to use synchronized.

public synchronized void deposit() {
    balance ++;
}

When you add synchronized to a method, Java automatically uses the object itself (in this case, your bankAccount instance) as a lock.

    * Thread 1 grabs the lock, runs the method.
    * Threads 2 through 10000 hit the locked door and OS put all those to sleep state (`BLOCKED`)
    * Thread 1 finishes, returns the lock, and wakes up Thread 2.
    * Repeat 10000 times.

synchronized works fine until many threads are constantly fighting for the same lock. When contention is high, thousands of threads hitting the same method, the constant blocking and waking becomes the bottleneck and you can end up slower than a single thread. That’s when you look for alternatives.

Lock-Free Concurrency (Atomics)

Instead of locks, we can use hardware-level instructions. Modern CPUs have a feature called Compare-And-Swap (CAS).
It says:
Update a value only if it has not been changed by another thread.

In Java, we use AtomicInteger which is built on CAS mechanism. Here is the code:

public class BankAccount {
    private AtomicInteger balance = new AtomicInteger(0);

    // No synchronized keyword needed!
    public void deposit() {
        balance.incrementAndGet();
    }

    public int getBalance() {
        return balance.get();
    }
}

If you run 10000 threads against this, your balance prints 10000 every single time. No locks, no blocking. But it’s not magic — see the note below

Note: CAS isn’t free. Under the hood, if the update fails, the thread retries in a loop — it doesn’t sleep, it spins. With few threads this is fast. With thousands of threads all spinning on the same value, you’re burning CPU on retries. synchronized at least puts threads to sleep. Pick based on your contention level.

What’s Next?

We will be discussing more on CompletableFuture, ConcurrentHashMap, CountDownLatch, DeadLocks and more in further posts.

Accessible error messages: the patterns that work across JAWS, NVDA and VoiceOver

Code comparison showing two HTML implementations of an inline error message. The

You added aria-live="assertive". You set aria-invalid="true". You tested in Chrome with a mouse.

The error message works perfectly.

Then a JAWS user fills out your form and hears… nothing useful. Or worse,they hear the error, but by the time they navigate back to the field, the message is gone.

Two real cases from audits that show exactly where this breaks.

Case 1: The toast that disappeared before the user could act
An e-learning platform displayed validation errors as auto-dismissing toasts with data-delay="8000". Eight seconds sounds reasonable until you map what a screen reader user actually needs to do: hear the announcement, understand the error, navigate back to the field, correct it, re-submit. On a complex form, that’s not enough time, and the message was gone before they could act.

WCAG failure: SC 2.2.1 — Timing Adjustable. Any time limit must be removable or adjustable. An 8-second toast carrying critical error information fails outright.

Fix: don’t use auto-dismissing toasts for errors. Toasts work for redundant, low-stakes notifications. Not for information the user needs to act on. Display errors inline, adjacent to the field, and keep them visible until the user corrects the input.

Case 2: The error that vanished when focus left the field
A form showed the error while the field was focused, but an onblur listener hid the container the moment the user tabbed away. The screen reader announced only “invalid entry.” No explanation, no guidance.

Two bugs in one component. First, the message disappeared on blur, so any user who tabbed to the next field and back found it gone. Second, there was no aria-describedby linking the error text to the input,so even when the message was visible, JAWS and NVDA had no reason to read it on focus.

Code comparison showing two HTML implementations of an inline error message. The

With aria-describedby in place, JAWS, NVDA and VoiceOver all read the error automatically when the field receives focus. Remove the blur handler. Errors should persist until the user successfully corrects the field.

Both bugs come down to the same root cause: errors that aren’t persistent and aren’t programmatically connected to their field. Fix those two things and you cover the vast majority of screen reader failure modes on form validation.

What patterns are you using for error handling? Curious if anyone’s hit edge cases with VoiceOver + Safari on iOS, that combination still has quirks worth discussing.