I Wanted an AI-Managed Blog. I Ended Up Using Git as the Database.

I Wanted an AI-Managed Blog. I Ended Up Using Git as the Database.

By Yuriy Zhar 5 min read
Why ExBlog keeps articles in Git and serves them from ETS, and how it handles atomic index replacement and concurrent readers in Phoenix.

I had a fairly clear idea for ExBlog: a blog built in Elixir where an AI agent could help me write, edit, translate, and publish articles, including from Telegram or an MCP client.

The agent was the interesting part, but first I had to decide where the content would live.

The obvious answer was PostgreSQL. An articles table, some JSON metadata, Ecto queries, done. It's an architecture I know well and it usually works beautifully.

Still, something felt unnecessarily complicated.

My articles were already Markdown documents. I wanted to edit them in a normal editor, compare versions, roll back a change, and maybe review a diff before publishing.

I was practically describing Git.

So why not use it?

Git isn't built to answer Phoenix requests

Git is good at managing content, but I wasn't going to run Git operations every time someone opened an article.

I could have read the Markdown files directly from disk. With ten posts, that probably wouldn't have mattered much.

But repeatedly loading files, parsing front matter, and rebuilding metadata for every HTTP request didn't feel like a design I'd want to keep.

Adding a cache on top of Git while keeping PostgreSQL would have left me with three representations of the same articles.

Instead I made the responsibilities explicit: Git owns the canonical content; ETS holds the read-ready representation.

At startup and during synchronization, ExBlog reads the Markdown files, parses them, and builds an ETS index keyed by {language, slug}.

From Phoenix, retrieving an article becomes an ETS lookup:

def get(lang, slug) do
  case read_table(
         &:ets.lookup(&1, {lang, slug})
       ) do
    [{{^lang, ^slug}, article}] -> article
    [] -> nil
  end
end

Simple enough.

Until the repository changes.

The small detail that could have ruined a clean design

Imagine 500 articles and a new commit changing a handful of them.

I need to rebuild the index. The easy approach would be :ets.delete_all_objects/1, followed by reinserting the latest records.

But Phoenix doesn't stop serving traffic while ETS is being rebuilt.

A request arriving halfway through might see only part of the blog. Nothing has been lost permanently; moments later the index is complete again. But to the reader, the missing article looks like a 404.

I needed a way to make a complete replacement visible all at once.

I ended up with a tiny pattern I really like: build a fresh ETS table, populate it completely, and change one reference.

The important section of ExBlog.Content.Index is just:

# The new table already contains every article

old_table = state.table

:persistent_term.put(@table_key, table)

:ets.delete(old_table)

The active table ID is stored in :persistent_term, where readers can retrieve it.

While I prepare the next version, readers keep using the old table. Once the replacement is ready, I publish the new reference.

I'm not updating the index underneath the readers. I'm replacing their view of it.

That solved the incomplete-index problem, but exposed another little trap.

A valid reference can become invalid a moment later

A Phoenix process can read the old table reference just before the swap.

Meanwhile, the index process publishes the new table and deletes the old one.

Now the reader tries to look up a key in a table that no longer exists.

That can raise ArgumentError.

My implementation takes the pragmatic route: fetch the current table reference and retry if that race happens.

defp read_table(fun, attempts \\ 2)

defp read_table(_fun, 0), do: []

defp read_table(fun, attempts) do
  table = :persistent_term.get(@table_key, nil)

  if is_reference(table) do
    fun.(table)
  else
    []
  end
rescue
  ArgumentError ->
    read_table(fun, attempts - 1)
end

It isn't a blanket guarantee against every race. After two attempts, this implementation can return an empty list, which Phoenix might treat as a missing article. The narrower useful property is that a successful read consults a fully built table, not one being populated.

I added a test for the property I actually cared about: twenty concurrent readers access the index while one article is replaced.

In that test, every reader observed either the complete old set or the complete new one, never an intermediate partial index. That's the outcome the test establishes, not a promise that every future read must succeed despite the limited retries.

That tiny test tells me more about the architecture than a long description of its modules.

And can the AI agent really write to Git?

Yes, but I wanted another boundary there.

The agent can prepare content and propose changes. Operations that modify the repository still go through confirmations and application-owned code.

The editorial path includes writing Markdown, making a Git commit, reconciling with the remote branch, pushing, and rebuilding the index.

I don't use force-push to bulldoze conflicts. If a rebase fails, I'd rather surface an error than silently overwrite someone else's work.

There's a larger consistency lesson here as well: the remote Git repository, the local filesystem, and ETS aren't participants in one ACID transaction. A failed push needs a real error path; it shouldn't be dressed up as a successful publication.

I wouldn't use this for everything

Rebuilding the index means reading the content again and making a second representation in memory. Updating :persistent_term also isn't free; it can trigger garbage collection work across the VM.

If I were building a platform with thousands of writes per second, I probably wouldn't choose this approach.

ExBlog is an editorial system, though. Reads are frequent; writes are relatively rare.

The agent's operational state still lives separately in DETS, and Telegram has its own session storage. I never set out to make Git a universal persistence layer.

I just wanted to avoid introducing a second canonical database for documents Git already knows how to manage.

In the end, the satisfying part wasn't getting rid of PostgreSQL. It was finding a fairly small way to combine Git's history with ETS's fast reads, without making Phoenix wait for the editorial workflow.


Spectre for Elixir
Recommended Tool

Spectre for Elixir

Build one Elixir agent. Give it only the powers it needs

Share this article:
Yuriy Zhar

Yuriy Zhar

github.com

Passionate web developer. Love Elixir/Erlang, Go, TypeScript, Svelte. Interested in ML, LLM, astronomy, philosophy. Enjoy traveling and napping.

Get in Touch

Have a question or want to work together? Drop a message below.

Book a Call

Stay updated

Subscribe to our newsletter and get the latest articles delivered to your inbox.

Stay curious.

Good ideas start with one more question.

Tap the rain. Make a little ripple.

Back to top