Demo data in a JSON file

One of the less glamorous but genuinely important parts of building Jelly has been figuring out how to show people what it does.

Jelly is a shared email tool for teams. That sounds simple enough, but when someone visits the site and thinks “would this work for my team?”, they need to see something that looks like their kind of work. A veterinary clinic has different needs to a creative agency. A neighbourhood events committee doesn’t look like a motorhome dealership. If every demo looks like “My Test Team” with lorem ipsum emails, you’re asking people to do all the imaginative work themselves.

So I built a system to generate realistic demo teams, and I use it every single day.

The shape of a team

Each demo team is a JSON file. Here’s the rough structure:

{ "name": "Acme Tools", "account_addresses": [ { "name": "Sales", "email_address": "sales@acmetools.test" }, { "name": "Support", "email_address": "support@acmetools.test" } ], "team_members": [ { "name": "Sarah Chen", "email": "sarah@acmetools.test", "signature": "Sarah Chen<br><em>Sales Manager</em>" } ], "contacts": { "customer@constructionclient.com": { "name": "Pat Rivera" } }, "conversations": [] }

Team members with realistic names, roles, and signatures. External contacts who feel like real customers or partners. Shared email addresses that match the kind of organisation. And then: conversations.

Conversations tell the story

The conversations array is where it gets interesting. Each conversation is a sequence of events – incoming emails, outgoing replies, internal comments, assignments, labels being applied, conversations being archived. A conversation might look like this:

{ "account_address": "sales@acmetools.test", "subject": "Bulk order inquiry - rubber hammers", "events": [ { "type": "message", "direction": "incoming", "from": "pat@riverahardware.test", "date": "2025-01-26 09:00:00", "body": "<p>Hi, we're interested in placing a bulk order...</p>" }, { "type": "assignment", "assigned_to": "sarah@acmetools.test", "date": "2025-01-26 09:15:00" }, { "type": "comment", "by": "sarah@acmetools.test", "body": "I'll handle this — @mike@acmetools.test can you check stock?", "date": "2025-01-26 09:20:00" } ] }

The @ mentions in comments get converted into proper mentions between members of the team.

You’ll notice all the timestamps in there are from 2025, but that doesn’t matter; dates get automatically shifted by a consistent amount so the most recent activity is always yesterday – the conversations feel current rather than frozen in time.

Spinning up a niche

Running bin/setup-demo.rb vet-clinic creates a complete team with all its members, addresses, labels, and a full history of conversations. All the activity logging, the assignments, the internal notes – it all happens through the same code paths that real usage would take.

I’ve got demo teams for a vet clinic, a creative agency, a motorhome dealership, a neighbourhood events committee, and a few others. Each one embodies a particular niche, with industry-appropriate language, roles, and workflows. When I want to show Jelly to someone who runs a small non-profit, I can pull up the neighbourhood events team and they can see their kind of work reflected back at them.

LLet Me build more

Because I have a standardised, standalone, easy to parse format for these demo teams, if I need to create a new set of demo team data, I don’t have to painstakingly1 create every new user, contact and conversation by hand; I can pass it over to a tool that’s particularly well suited to stochaistically parroting out a big long string that’s similar to what it’s seem before: generative “AI”.

I can give Claude a copy of one of these files with the prompt “build me a demo data file using this format for a team who does …” – whatever, really – and in a few minutes I have a fully working team in Jelly that I can show to prospective users that they can immediately relate to.

Works great for development too

I don’t generate new teams everyday, but I do use this demo data every day. I’ve created essentially a huge suite of records perfectly suited for click-testing and exploring changes without needing to touch production to feel realistic.

And I’ve built one final bonus that really helps me locally:

  1. a comand to rebuild me a clean database with every single demo team instantiated into real model data in the database
  2. a pair of commands to
    • produce a dump file of the development data, and
    • restore the development data from that dump.

This means I can do a whole bunch of development, run migrations, alter the schema, edit conversations, and then when I want to get the database2 back to a clean state, simply wipe clean and reload the dump.

if Rails.env.development? task "db:dump" do sh "pg_dump -Fc -v --no-owner --no-acl inboxtopus_development > tmp/dump.pdump" puts "Database dumped" end task "db:restore" do sh "touch tmp/restart.txt" # close any open connections to the database sh "rails db:drop db:create" # get a clean database sh "pg_restore -v --no-owner --no-acl -d inboxtopus_development tmp/dump.pdump" puts "Database restored" end end

It’s way faster than loading a seed file and having to re-run all of the logic to actually create this data.

  1. The first set of demo data we created actually was written by hand, more like a typical seed file. We all imagined a bunch of email conversations that might’ve taken place in the Bluth Company. But it turns out that showing conversations from a disfunctional business (and family), while very funny, isn’t necessarily the best way to “sell” a productivity product 😉. That team does live on though – the data has since been translated into JSON, and lives alongside the others. 

  2. Jelly’s codename was “inboxtopus” 

Self-updating screenshots

I think this might be the neatest thing I’ve built in Jelly that nobody will ever notice.

If you’ve ever maintained a help centre or documentation site for a web application, you’ll know the particular misery of screenshots. You write a lovely help article, carefully capture a screenshot of the feature you’re documenting, crop it, maybe add a border and a shadow, upload it, and it looks great. Then you change the UI slightly – tweak a colour, move a button, update some copy – and suddenly every screenshot that includes that element is stale. You know they’re stale. Your users might not notice, but you know, and it gnaws at you.

Or maybe that’s just me.

Either way, I decided to fix it. The help centre in Jelly has a build system where screenshots are captured automatically from the running application, and they update themselves whenever you rebuild.

Markdown with a twist

The help articles are written in Markdown, which gets processed into HTML via Redcarpet and then rendered as ERB views in the Rails app. So far, so ordinary. But scattered through the Markdown are comments like this:

<!-- SCREENSHOT: acme-tools/inbox | element | selector=#inbox-brand-new-section -->
![The "Brand New" section](images/basics-brand-new-section.png ':screenshot')

That HTML comment is an instruction to the screenshot system. It says: “go to the inbox page for the Acme Tools demo team, find the element matching #inbox-brand-new-section, and capture a screenshot of it.” The image tag below it is where the result ends up.

How it works

Under the hood, it’s a Rake task that fires up a headless Chrome browser via Capybara and Cuprite. It scans every Markdown file for those SCREENSHOT comments, groups them by team (so it only needs to log in once per team), navigates to each URL, and captures the screenshot.

The capture modes are:

And there are a handful of options that handle the fiddly cases:

<!-- SCREENSHOT: nectar-studio/manage/rules | full_page | click=".rule-create-button" wait=200 crop=0,800 -->

That one navigates to the rules page, clicks a button to open a form, waits 200 milliseconds for the animation, then captures a full-page screenshot cropped to a specific region. The click option is the one that really makes it sing – so many features live behind a button press or a popover, and being able to capture those states automatically is wonderful.

There’s also torn – which applies a torn-paper edge effect via a CSS clip-path – and hide, which temporarily hides elements you don’t want in the shot (dev toolbars, cookie banners, that sort of thing).

The satisfying bit

The whole pipeline runs with just this:

rails manual:build

That captures every screenshot and then builds all the help pages. When I change the UI, I run that command and every screenshot updates to match. No manual cropping, no “oh I forgot to update that one”, no slowly-diverging screenshots that make the help centre look abandoned.

The markdown files live in public/manual/, organised by section – basics, setup, advanced – and the build step processes them into ERB views in app/views/help/, complete with breadcrumbs and section navigation, all generated from the source markdown files.

This also makes it easy to update the help centre at the same time I’m working on the feature; the code and the documentation live together and can be kept in sync within the same PR or even commit.

One of those “why didn’t I do this sooner” things

I put off building this for ages because it seemed like a lot of work for a “nice to have”. It was a fair bit of work, honestly. Handling the edge cases – elements that need scrolling into view, popovers that need clicking, images that need cropping to avoid showing irrelevant content – took longer than the happy path.

But now that it exists, I update the help centre far more often than I used to, because the friction is almost gone. Change the UI, run the build, commit the results. The screenshots are always current, and I never have to open a browser and fumble around with the macOS screenshot tool.

What I'm doing now

So. It’s been a minute1.

I last published2 something here a year and a half ago, and then it went quiet. So! Let me tell you what I’m up to.

If you’ve been following along3 you’ll know that since mid-2023, I was part4 of Good Enough, a little company of friends making small, thoughtful software products:

With dizzying ideals and lofty ambition, Good Enough rose higher and higher, into the cosmic expanse… and then gracefully wound down, like a firework that bursts beautifully and then scatters into a handful of glowing embers, each drifting off in its own direction.

(I am making light of this but in reality it was a process undertaken with a level of conscientiousness, generosity and kindness that I doubt I’ll ever encounter again. What a time. I had the space to think about things like this and coin phrases like “Remototem”. I built an unreleased product called “Chicken”. What a time.)

From those embers, a few things flourished. Pika and LetterBird carried on with the reborn Good Enough.

The glowing ember that is Jelly… came with me.

Jelly

I’ve wanted something like Jelly since the very first days of helping organise Ruby Manor. Back then we were a handful of volunteers trying to coordinate everything over a tangle of email threads, losing track of who’d replied to what, things falling through cracks.

I remember thinking: all I want is a way for me and the friends working on this conference to be able to see the same emails, see who has replies to what, jump in and help out, all without it being a complete disaster, and – importantly – without paying through the nose for it.

That’s Jelly. Shared email for small teams (companies, groups, collectives, rag-tag vagabonds, whoever). And I love that it exists.

Here’s what I care about most: keeping it affordable. No per-seat pricing that punishes you for growing. Deciding who can contribute to your conversations with the world shouldn’t ever come down to “can we afford it”.

The basic plan covers everything I would’ve wanted for Ruby Manor and then some – shared inboxes, assignments, internal notes, labels, the works. The Royal Plan serves teams with more needs – API access, integrations, scheduled sending, that sort of thing – and that extra revenue helps make the whole service sustainable so that we can keep the price low for the community groups and non-profits who need it most.

I’m always looking at ways to make it more affordable while still being sustainable. That balance is tricky, but it’s the one I care about getting right.

I’m also really proud of the documentation that Jelly has (and I’ll write more about that soon). You can start with The Jelly Philosophy. What’s that, you say? A manifesto? Well… you said it, not me.

Other things

I’m also doing some consulting work, which helps keep food on the table while Jelly grows. It’s a good balance, actually. The consulting gives me a regular change of context, and then I come back to Jelly with fresh eyes and renewed energy for the product work.

Writing

I’ve got a few posts brewing about some of the more interesting technical things I’ve built in Jelly recently. A system for generating realistic demo data. An automated screenshot pipeline for the help centre. A neat feature flag architecture. The small, specific, slightly-obsessive solutions that make daily life as a solo developer that little bit better.

More soon. Probably.

  1. Actually, 742140 minutes, give or take. 

  2. I say published because since September last year I have written a lot, about a particular community governance… situation, and about a particular community leader… situation, none of which felt sufficient to either communicate my frustration, nor sufficient to make a dent in anything, and all the while I processed my own feelings and ideas about what’s right and wrong and what crosses “the line”, so to speak. TL;DR – what a fucking mess

  3. Let’s be honest, given the frequency of posts here, “following along” is a generous description of what you’ve been allowed to do. 

  4. This post is dated mid-2025, but I promise you, I actually joined the gang two years earlier. We were just rubbish at marketing ourselves. 

Not all accusations are baseless

They don’t work anymore, those baseless accusations that anyone we disagree with is a racist, misogynist, fascist.

OK, so, DHH wrote this. The post, I assume (see later) largely written to say “look, I was right about DEI”, also pretty clearly suggests that applying those labels to Trump is “baseless”, and that the election demonstrated that people now see through a noisy minority throwing those accusations – at Trump in this case – for the overall good of everyone. The wisdom of the crowd has overcome political correctness, maybe.

Erk.

The problem with accusations like that is that they eventually have to be backed by proof or they’ll bounce like a rubber check. And when even the most mundane political or moral positions were able to earn one of those *ist-y labels, the entire enterprise of throwing them around was bound to go bankrupt. And now it has.

It’s impossible to consider Trump’s political or moral positions as mundane, so given this post is actually about the election it’s very unclear to me why we’d be talking about mundane positions. When we talk about Trump, nothing is mundane or even moderate. Not even his supporters would describe him as that.

That big scary mob that once was has been reduced to near impotence in most arenas, including the biggest of all, the US presidential election. That’s a win for everyone whether they like Donald Trump or not.

If I had to summarise David’s post, it’d be something like: “all the people who voted for Trump demonstrated that throwing around terms like ‘racist’ and ‘fascist’ doesn’t work to scare people into behaving a certain way, and that’s a good thing.”

Hopefully even he would agree that was a fair summary.

But what if the accusations aren’t baseless?

His ex Chief of Staff thinks he fits the general description of a fascist. His disrespect for the democratic process is clearly authoritarian. He has stated plainly that he wants to silence or remove his opposition.

He fits the definition. Calling him a fascist is hardly baseless, unless you’re going to argue “oh, he’s just saying that, he doesn’t actually believe it or won’t actually do it.” I don’t buy that.

His anti-immigrant claims that immigrants are eating pets are racist. So many other instances of racism are well documented.

You might be able to argue that he’s never been technically convicted of rape, but a jury found him liable for battery through sexual assault. The evidence of his disrespect for women is so easy to find, and so irrefutable, that I won’t even both linking to it.

So these accusations, applied to Trump, are about as far from baseless as you can get.

What, then, can we learn from the election result about baseless accusations then?

Not much, I’d say.

But what if Trump’s win actually demonstrates, terrifyingly, that people don’t care even that he demonstrates racist and fascist behaviours? That they don’t care he’s a conviced criminal, that he’s been indicted four times, that he has no regard for the law. What if the election shows that none of that matters, and that people will vote for him anyway, no matter how corrupt and craven he fucking demonstrably is?

What if the “bankrupcy” is actually the majority’s willingness to overlook objective facts and instead wallow in an individualist fantasy being served up by a wannabe strongman feeding them bullshit stories about how their problems are all somebody else’s fault, and how he’s going to somehow restore them to a “greatness” that only ever existed in a fairy tale? How is that a win for everyone?

OK, look, sorry for the swear. Maybe I’m reading more into what David has written than he intended. I’ve read what he wrote a bunch of times and I’ve tried to find the generous read. I’ve actively worked to try to understand the point he’s trying to make.

I have introspected enough to know that it’s human nature to view events through a lens that reinforces and supports our existing opinions, so I’m not surprised that the election makes David think about DEI. And he can, of course, say whatever he wants, and I respect his right to do that.

And I genuinely appreciate what he contributes, apolitically, to the community I love, even if I disagree with where some of his non-development trains of thought have led him.

But, IMHO, this post seems to be, at best, a pointless and stretched-thin linking of DEI to “woke mob accusations” to the election of a singularly, objectively not good person into a position of incredible power.

And at worst: deliberately suggesting that Trump’s win is “good for everyone” because it demonstrates that calling anyone a fascist or a racist or a sexist or a bigot or a misogynist no longer has any impact on their ability to progress. No matter how accurate those labels actually are.

You might be able to keep politics out of your workplace. But you can’t keep politics out of politics.

Not everyone I disagree with is a racist, misogynist fascist.

But that doesn’t mean Donald Trump isn’t.

Because he very, very obviously is.

Conversations are hard

Sometimes I find talking to people very hard. Even people I like a lot. And I’ve been thinking about why.

This (ugh) Quora answer actually summarises it pretty well:

Question askers navigate conversations by asking questions, getting responses, asking more questions, and hoping that they will get a question in return so that they can talk about themselves a little too. They will listen to openly shared stories, but they won’t openly share their own stories.

Open sharers will choose topics about their own lives to share, and hope that the other person will do the same. They will answer questions, but won’t ask them.

What I’ve noticed is that, while I can do “open sharing”, I am much more often a “question asker”. I listen attentively to your stories and I ask questions to help you talk more about whatever is interesting you at the moment.

But what I’ve also noticed, is that it really sucks sometimes when I’m talking to an open sharer, even if I really like the person, when they never switch modes and ask any questions1. In those conversations, it feels like the onus is always on me to switch modes and volunteer some hopefully-interesting thought or anecdote if I want to feel like I’m anything other than my conversational partner’s therapist. Doing that time after time eventually feels like I’m pushing for attention, which is icky and exhausting. I would like to feel like I am interesting to you without having to beg for your attention.

I don’t think that open sharers are bad people; it’s just how they’ve learned to interact, and when they talk with other open sharers then it works totally fine for them. But sometimes it feels like they are oblivious to the fact that not everyone is the same as them. They might not even realise they are an open sharer, since their mode is by default the one that “creates” conversation.

Think about what kind of conversationalist you are. Really think about it. In your last conversation with your good friend, did you ask them any questions? Have a scroll up. When was the last time you asked them anything meaningful at all? Has it been a while? Guess what, buddy: you might be an open sharer.

My suggestion to all you open sharers out there, if you actually care about how the person you are talking to feels: pay attention in your conversations, and if it feels like your partner hasn’t said much, then maybe ask them a fucking question or two.

  1. OK, to be fair, conversations with an extreme question asker can also be pretty unpleasant; even for another question asker they can feel like an interrogation. 

Weeknotes 2041

Work

A lot of my work at the moment involves chasing down bugs in an incredibly complicated publishing system which has multiple triggers (both internal and external) that make films available to watch on a variety of platforms at specific times in specific countries. When bugs creep in, films either stay up or don’t go up, and sometimes that annoys the wrong people. This week was one of those weeks, but involving a bug that was introduced about two years ago, and then fixed 8 months later, so calculating the true impact of the issue is pretty difficult. This is definitely why I got into software development.

Not work

Does this site now run on Ruby 3.1? I hope it does. I’ve been slowly working through a bunch of my other small applications, trying to update them to modern versions of Ruby (and even Rails), so that at the very least they remain deployable. This site is one of those small applications.

My real job

As the parent of an infant, I am now accustomed to exhaustion, and within that broad label, all the subtly-distinct flavours it comes in. This week has mostly been standard not-enough-contiguous-hours-of-unconsciouness tiredness. Number of thousand-yard stares while the child does not immediately need my attention: 5.

Physical health

None of this is helped by having yet another cold of some kind. Which of course we all get, and so it’s a never ending cycle of minor illness and sleep distruption and nobody is feeling very jazzy or sparkly but at the same time, the kid is so damned adorable that it remains worth it.

Mental health

Some rare adult socialisation in the form of a birthday party for another child (but yes, it still counts), and then Tom & Nat coming over for lunch today, both of which were very excellent.

Door

Today I found a door in my hallway that I hadn’t noticed before, but I’m currently too tired to investigate further.

Connoisseur

Listen, here’s what I’m wondering: is it better to be able to appreciate subtle refinements and improvements of a thing, the finest examples of it, if it comes at the expense of enjoying the version of that thing that’s most commonly or easily available?

If that “better” thing gives an increase of happiness of X, but the more commonly available instances – in their relative worseness – now gives a decrease of happiness of Y, then how often is the sum of X greater than the sum of Y?

Is it better to really get into, say, wine, for all the increased enjoyment that those few really great wines can provide, if it means that you either need to spend a new multiple on acquiring that wine, or suffer increasing disappointment from the wines you can affort or are offered at friends houses?

Or is it better – on the whole – to remain largely ignorant of what distinguishes a “great” wine from an “ok” wine, and just enjoy them all to a lesser but more consistently positive extent?

It might not be wine, it could be chocolate, or coffee, or art, or pretty much anything else that you can develop a “taste” for. Are the newly-available highs of pleasure worth, overall, the lessened ability to tolerate the instances of that thing which do not match your new palate?

This is what I’m wondering. Sound out in the comments1

The only clue I can offer is that it’s a literal nightmare figuring out how to spell “connoisseur”. I had to look it up to write this. Twice.

The universe weaves clues into its ineffable fabric, I suppose.

  1. JK there are no comments here – literal LOL – nah, you go scream into whatever digital silo you prefer. 

This is for Patrick

This is for Patrick, who doesn’t subscribe to the RSS or Atom feeds.

Instead he sets a reminder to check this website every week or two (or perhaps less, I forget), and – upon seeing no new posts – sets another reminder to do the same later that month, or year, or whenever.

This is for Patrick, who has lost so many precious seconds checking this site for new posts, only to find nothing.

I won’t be responsible for this anymore. I won’t burn his time and energy like electron potential on the bitcoin bonfire. I won’t be the source of that disappointment. My stale blog-type thing won’t be the reflection in that lone tear as it traces its melancholy path down his face. Not anymore.

This post, Patrick… buddy… this is for you.

Don't cache ActiveRecord objects

Hello, new Rails developer! Welcome to WidgetCorp. I hope you enjoyed orientation. Those old corporate videos are a hoot, right? Still, you gotta sit through it, we all did. But anyway, let’s get stuck in.

So as you know, here at WidgetCorp, we are the manufacturer, distributor and direct seller of the worlds highest quality Widgets. None finer!

These widgets are high-value items, it’s a great market to be in, but they’re also pretty complicated, and need to be assembled from any number of different combinations of doo-hickeys, thingamabobs and whatsits, and unless we have the right quantities and types of each, then a particular kind of widget may not actually be available for sale.

(If you just came for the advice, you can skip this nonsense.)

But those are manufacturing details, and you’re a Rails developer, so all it really means for us is this: figuring out the set of available widgets involves some necessarily-slow querying and calculations, and there’s no way around it.

In our typical Rails app, here’s the code we have relating to showing availble widgets:

# db/schema.rb
create_table :widgets do |t|
  t.string :title
  # ...
end

# app/models/widget.rb
class Widget < ActiveRecord::Base
  scope :available, -> { 
    # some logic that takes a while to run
  }
end

# app/helpers/widget_helper.rb
module WidgetHelper
  def available_widgets
    Widget.available
  end
end
<!-- app/views/widgets/index.html.erb -->
<% available_widgets.each do |widget| %>
  <h2><%= widget.name %></h2>
  <%= link_to 'Buy this widget', widget_purchase_path(widget) %>
<% end %>

It’s so slow!

The complication of these widgets isn’t our customer’s concern, and we don’t want them to have to wait for even a second before showing them the set of available widgets that might satisfy their widget-buying needs. But the great news is that the widget component factory only runs at midnight, and does all its work instantaneously1, so this set is the same for the whole day.

So what can we do to avoid having to calculate this set of available widgets every time we want to show the listing to the user, or indeed use that set of widgets anywhere else in the app?

You guessed it, you clever Rails developer: we can cache the set of widgets!

For the purposes of this example, let’s add the caching in the helper:2

module WidgetHelper
  def available_widgets
    Rails.cache.fetch("available-widgets", expires_at: Date.tomorrow.beginning_of_day) do
      Widget.available
    end
  end
end

Bazinga! Our website is now faster than a ZipWidget, which – believe me – is one of the fastest widgets that we here at WidgetCorp sell, and that’s saying something.

Anyway, great work, and I’ll see you tomorrow.

The next day

Welcome back! I hope you enjoyed first night in the company dorm! Sure, it’s cosy, but we find that the reduced commute times helps us maximise employee efficiency, and just like they said in those videos, say it with me: “An efficient employee is… a…”

… more impactful component in the overall WidgetCorp P&L, that’s right. But listen to me, wasting precious developer seconds. Let’s get to work.

So corporate have said they want to store the colour of the widget, because it turns out that some of our customers want to coordinate their widgets, uh, aesthetically I suppose. Anyway, I don’t ask questions, but it seems pretty simple, so let’s add ourselves the column to the database and show it on the widget listing:

# db/schema.rb
create_table :widgets do |t|
  t.string :title
  t.string :colour
  # ...
end
<!-- app/views/widgets/index.html.erb -->
<% available_widgets.each do |widget| %>
  <h2><%= widget.title %></h2>
  <p>This is a <%= widget.colour %> widget</p>
  <!-- ...  -->
<% end %>

… aaaaaaaand deploy.

Great! You are a credit to the compa WHOA hangon. The site is down. THE SITE IS DOWN.

Exceptions. Exceptions are pouring in.

“Undefined method colour”? What? We ran the migrations right? I’m sure we did. I saw it happen. Look here, I’m running it in the console, the attribute is there. What’s going on? Oh no, the red phone is ringing. You answer it. No, I’m telling you, you answer it.

What’s going on

The reason we see exceptions after the deployment above is that the ActiveRecord objects in our cache are fully-marshalled ruby objects, and due to the way ActiveRecord dynamically defines attribute access, those objects only know about the columns and attributes of the Widget class at the time they entered the cache.

And so here’s the point of this silly story: never store objects in your cache whose structure may change over time. And in a nutshell, that’s pretty much any non-value object:

Marshalling data

If you take a look at how Rails caching actually works, you can see that under the hood, the data to be cached is passed to Marshal.dump, which turns that data into an encoded string.

We can see what that looks like here:

$ widget = Widget.create(title: 'ZipWidget')
$ data = Marshal.dump(widget)
# \x04\bo:\vWidget\x16:\x10@new_recordF:\x10@attributeso:\x1EActiveModel::AttributeSet\x06;
# \a{\aI\"\aid\x06:\x06ETo:)ActiveModel::Attribute::FromDatabase\n:\n@name@\b:\x1C
# @value_before_type_casti\x06:\n@typeo:\x1FActiveModel::Type::Integer\t:\x0F@precision0
# :\v@scale0:\v@limiti\r:\v@rangeo:\nRange\b:\texclT:\nbeginl-\t\x00\x00\x00\x00\x00\x00\x00\x80:\b
# endl+\t\x00\x00\x00\x00\x00\x00\x00\x80:\x18@original_attribute0:\v@valuei\x06I\"\ntitle\x06;
# \tTo;\n\n;\v@\x0E;\fI\"\x0EZipWidget\x06;\tT;\ro:HActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::MysqlString\b;
# \x0F0;\x100;\x11i\x01\xFF;\x170;\x18I\"\x0EZipWidget\x06;\tT:\x17@association_cache{\x00:\x11
# @primary_keyI\"\aid\x06;\tT:\x0E@readonlyF:\x0F@destroyedF:\x1C@marked_for_destructionF:\x1E
# @destroyed_by_association0:\x1E@_start_transaction_state0:\x17@transaction_state0:\x17
# @inspection_filtero:#ActiveSupport::ParameterFilter\a:\r@filters[\x00:\n@maskU:
# 'ActiveRecord::Core::InspectionMask[\t:\v__v2__[\x00[\x00I\"\x0F[FILTERED]\x06;\tT:$
# @_new_record_before_last_commitT:\x18@validation_context0:\f@errorsU:\x18
# ActiveModel::Errors[\b@\x00{\x00{\x00:\x13@_touch_recordT:\x1D@mutations_from_database0: 
# @mutations_before_last_saveo:*ActiveModel::AttributeMutationTracker\a;\ao;\b\x06;\a{\a
# @\bo:%ActiveModel::Attribute::FromUser\n;\v@\b;\fi\x06;\r@\n;\x17o;\n\n;\v@\b;\f0;\r
# @\n;\x170;\x180;\x18i\x06@\x0Eo;0\n;\v@\x0E;\fI\"\x0EZipWidget\x06;\tT;\r@\x11;\x17o;\n\t;
# \v@\x0E;\f0;\r@\x11;\x170;\x18@\x10:\x14@forced_changeso:\bSet\x06:\n@hash}\x00F

If you look closely in there, you can see some of the values (e.g. the value ZipWidget), but there’s plenty of other information about the specific structure and implementation of the ActiveRecord instance – particularly about the model’s understanding of the database – that’s encoded into that dump.

You can revive the object by using Marshal.load:

$ cached_widget = Marshal.load(data)
# => #<Widget id: 1, title: "ZipWidget">

And that works great, until you try and use any new attributes that might exist in the database. Let’s add the colour column, just using the console for convenience:

$ ActiveRecord::Migration.add_column :widgets, :colour, :string, default: 'beige'

We can check this all works and that our widget in the database gets the right value:

$ Widget.first.colour
# => 'beige'

But what if we try and use that same widget, but from the cache:

$ cached_widget = Marshal.load(data)
# => #<Widget id: 1, title: "ZipWidget">
$ cached_widget.colour
Traceback (most recent call last):
        1: from (irb):14
NoMethodError (undefined method `colour' for #<Widget id: 1, title: "ZipWidget">)

Boom. The cached instance thinks it already knows everything about the schema that’s relevant, so when we try to invoke a method from a schema change, we get an error.

Workarounds

The only ways to fix this are

Not great.

But there’s a better solution, which is avoiding this issue in the first place.

Store IDs, not objects

Sometimes it is useful to take a step back from the code and think about what we are trying to acheive. We want to quickly return the set of available widgets, but calculating that set takes a long time. However, there’s a difference between calculating the set, and loading that set from the database. Once we know which objects are a part of the set, actually loading those records is likely to be pretty fast – databases are pretty good at those kinds of queries.

So we don’t actually need to cache the fully loaded objects; we just need to cache something that lets us quickly load the right objects – their unique ids.

Here’s my proposed fix for WidgetCorp:

module WidgetHelper
  def available_widgets
    ids = Rails.cache.fetch('available-widgets', expires_at: Date.tomorrow.beginning_of_day) do
      Widget.available.pluck(:id)
    end
    Widget.where(ids: ids)
  end
end

Yes, it’s less elegant that neatly wrapping the slow code in a cache block, but the alternative is a world of brittle cache data and deployment fragility and pain. If a new column is added to the widgets table, nothing breaks because we’re not caching anything but the IDs.

Bonus code: defend your app from brittle cache objects

It can be easy to forget this when you’re building new features. This is the kind of thing that only bites in production, and it can happen years after the unfortunate cache entered use.

So the best way of making sure to avoid it, is to have something in your CI build that automatically checks for these kinds of records entering your cache.

In your config/environments/test.rb file, find the line that controls the cache store:

# config/environments/test.rb
config.cache_store = :null

and change it to this:

# config/environments/test.rb
config.cache_store = NullStoreVerifyingAcceptableData.new

And in lib, create this class:

# lib/null_store_verifying_acceptable_data.rb
class NullStoreVerifyingAcceptableData < ActiveSupport::Cache::NullStore
  class InvalidDataException < Exception; end

  private

  def write_entry(key, entry, **options)
    check_value(entry.value)
    true
  end

  def check_value(value)
    if value.is_a?(ActiveRecord::Base) || value.is_a?(ActiveRecord::Relation)
      raise InvalidDataException, 
        "Tried to store #{value.class} in a cache. We cannot do this because \
        the behaviour/schema may change between this value being stored, and \
        it being retrieved. Please store IDs instead."
    elsif value.is_a?(Array) || value.is_a?(Set)
      value.each { |v| check_value(v) }
    elsif value.is_a?(Hash)
      value.values.flatten.each { |v| check_value(v) }
    end
  end
end

With this in place, when your build runs any test that exercises behaviour that ends up trying to persist ActiveRecord objects into the cache will raise an exception, causing the test to fail with an explanatory message.

You can extend this to include other classes if you have other objects that may change their interface over time.

Double bonus: tests for NullStoreVerifyingAcceptableData

How can we be confident that the new cache store is actually going to warn us about behaviours in the test? What if it never ever raises the exception?

When I’m introducing non-trivial behaviour into my test suite, I like to test that too. So here’s some tests to sit alongside this new cache store, so we can be confident that we can actually rely on it.

require 'test_helper'

class NullStoreVerifyingAcceptableDataTest < ActiveSupport::TestCase
  setup do
    @typical_app_class = Widget
  end

  test 'raises exception when we try to cache an ActiveRecord object' do
    assert_raises_when_caching { @typical_app_class.first }
  end

  test 'raises an exception when we try to cache a relation' do
    assert_raises_when_caching { @typical_app_class.first(5) }
  end

  test 'raises an exception when we try to cache an array of ActiveRecord objects' do
    assert_raises_when_caching { [@typical_app_class.first] }
  end

  test 'raises an exception when we try to cache a Set of ActiveRecord objects' do
    assert_raises_when_caching { Set.new([@typical_app_class.first]) }
  end

  test 'raises an exception when we try to cache a Hash that contains ActiveRecord objects' do
    assert_raises_when_caching { {value: @typical_app_class.first} }
  end

  test 'does not raise anything when caching an ID' do
    Rails.cache.fetch(random_key) { @typical_app_class.first.id }
  end

  test 'does not raise anything when caching an array of IDs' do
    Rails.cache.fetch(random_key) { @typical_app_class.first(5).pluck(:id) }
  end

  private

  def assert_raises_when_caching(&block)
    assert_raises NullStoreVerifyingAcceptableData::InvalidDataException do
      Rails.cache.fetch(random_key, &block)
    end
  end

  def random_key
    SecureRandom.hex(8)
  end
end

If you extend the cache validator to check for other types of objects, you can add tests to make sure those changes work as you expect.

Triple-bonus code: what if you already have cached objects?

My real fix for WidgetCorp would actually try to mitigate this issue while still respecting the (possibly broken) objects still in the cache:

module WidgetHelper
  def available_widgets
    ids_or_objects = Rails.cache.fetch('available-widets', expires_at: Date.tomorrow.beginning_of_day) do
      Widget.available.pluck(:id)
    end
    ids = if ids_or_objects.first.present? && ids_or_objects.first.is_a?(ActiveRecord::Base) 
      ids_or_objects.map(&:id)
    else
      ids_or_objects
    end
    Widget.where(id: ids)
  end 
end

This allows us to still use the ActiveRecord instances that are cached, while storing any new data in the cache as just IDs. Once this code has been running for a day, all of the existing data in the cache will have expired and the implementation can be changed to the simpler one.

  1. Pocket dimension, closed timelike curves, above your paygrade, don’t worry about it! 

  2. You might imagine we could get around all this by caching the view, and indeed in this case, it wouldn’t raise the same problems, but in a typically mature Rails application we end up using cached data in other places outside of view generation, so the overall point is worth making. Still, award yourself 1 gold credit. 

Two thoughts about maintainable software

I really enjoyed the most recent episode of Robby Russell’s podcast “Maintainable”, in which Glenn Vanderburg1 talks about capturing explanations about code and systems, along with managing technical debt. It’s a great episode, and I highly recommend pouring it into your ears using whatever software you use to gather pods.

The whole thing is good, but there were two specific points brought up fairly early in their conversation that got me thinking. What a great excuse to write a post here, and make me feel less guilty about potentially never publishing the Squirrel Simulator 2000 post I had promised.

I know; I’m sorry. Anyway, here’s some tangible words instead of that tease. Enjoy!

Documenting the why

At the start of the conversation, Robby asks Glenn what he thinks makes “maintainable” software, and part of Glenn’s response is that there should be comments or documents that explain why the system is designed the way it is.

Sometimes things will be surprisingly complex to a newcomer, and it’s because you tried something simple and you learned that that wasn’t enough. Or sometimes, things will be surprisingly simple, and it’s good to document that “oh, well we thought we needed it to be more complicated, but it works fine for [various] reasons.”

Robby goes on to ask how developers might get better at communicating the “why” – should it be by pointing developers at tickets, or user stories somewhere, or it should be using comments in the code, or maybe some other area where things get documented? And without wanting to directly criticise Glenn’s answer, it brought to mind something that I have come to strongly believe during my career writing software so far: the right place to explain the why is the commit message.

But why not comments?

I’ve lost count of the number of times that I’ve found a comment in code and realised it was wrong. We just aren’t great at remembering to keep them updated, and sometimes it’s not clear when a change in the implementation will reduce the accuracy of the comment.

But it’s already fully explained in the user story/Basecamp thread/Trello card…

I’ve also lost count of the number of times that a commit message contains little more than a link, be it to a defunct Basecamp project, or a since-deleted Trello card, or am irretrievable Lighthouse ticket, or whatever system happened to be in use ten years ago, but is almost certainly archived or gone now, even if the service is still running.

And even if we are lucky and that external service is still running, often these artefacts are discussions where a feature or change or bug is explored and understood and evolved. The initial description of a feature or a bug might not reflect the version we are building right now, so do we really want to ask other developers and our future selves to have to go back to that thread/card/ticket and re-synthesise all those comments and questions, every time they have a question about this code?

Put it in the commit message

Comments rot. External systems, over the timescales of a significant software product, are essentially ephemeral. For software to be truly maintainable, it needs to carry as much of the explanation of the why along with it, and where the code itself cannot communicate that, the only place were an explanation doesn’t rot is where it’s completely bound to the implementation at that momment in time. And that’s exactly what the commit message is.

So whenever I’m writing a commit, I try and capture as much of the “why” as I can. Yes, I write multi-paragraph commit messages. The first commit for a new feature typically includes an explanation of why we’re building it at all, what we hope it will do and so on. Where locally I’ve tried out a few approaches before settling on the one I prefer, I try and capture those other options (and why they weren’t selected) inside the commit message for the approach I did pick.

Conversely, if I am looking at a line of code or a method and I’m not sure about the why of it, I reach for git blame, which immediately shows me the last time it was touched, and (using magit or any good IDE) I can easily step forward and back and understand the origin and evolution of that code – as long as we’ve taken the time to capture the why for each of those changes. And this applies just as much to the code that I wrote as it does to other developers. Future-me often has no idea why I made certain choices – sometimes I can barely remember writing the code at all!

So anyway, in a nutshell, when you’re committing code, try to imagine someone sitting beside you and asking “why?” a few times, and capture what your explanation would be in your commit message.

BONUS TIP: finding that your “why” explanation is a bit too long? That’s probably a signal that you need to make smaller commits, and take smaller steps implementing your feature. I’ve never regretted taking smaller steps, even though I recognise sometimes the desire to “just get it done” can be strong.

Using tests as TODOs

A little later, the conversation turns to the merits of “TODO” comments in the code, and how to handle time-based issues where code needs to exist but perhaps only for a certain amount of time in its current form, before it need to be revisited. It’s not unusual for startups to need to get something into production quickly, even if they know that they are spending technical debt in doing so. Glenn correctly points out that it can be hard to make sure that comments like that get attention in the future when they should.

The problem is that when you put them in there, there’s no good way to make sure you pay attention when the time comes [to address the debt]

I spend a fair bit of my professional time working with a startup that has a very mature (13 years old) Rails codebase, and more often than not, moving quickly involves disabling certain features temporarily, as well as adding new ones. For example, on occasion we might need to disable the regular newsletter mechanism and background workers while the company runs a one-off marketing campaign. To achieve this, we need to disable or modify some parts of the system temporarily, but ensure that we put them back in place once the one-off campaign is completed.

In situations like this, I have found that we can use the tests themselves to remind us to re-enable the regular code. Here’s a very simple test helper method:

def skip_until(date_as_string, justification)
  skip(justification) unless Date.parse(date_as_string).past?
end

Becase we have tests that check the behaviour of our newsletter system under normal conditions, when we disable the implementation temporarily, these tests will naturally fail, but we can use this helper to avoid the false-negative test failures in our continuous-integration builds, without having to delete the tests entirely and then remember to re-add them later:

class NewsletterWorkerTest < ActiveSupport::TestCase
  should "deliver newsletter to users" do
    skip_until('2020-12-01', 'newsletters are disabled while Campaign X runs')
    
    # original test body follows
  end
  
  # other tests 
end

This way, we get a clear signal from the build when we need to re-enable something, without the use of any easy-to-ignore comments at all.

Not all comments?

I’m not completely opposed to all comments, but it’s become very clear to me that they aren’t the best way to explain either what some code does, or why it exists. If we can use code itself to describe why something exists, or is disabled – and then remind us about it, if appropriate – then why not take advantage of that?

And if there’s only one thing you take away from all the above, let it be this: take the time to write good commit messages, as if you are trying to answer the questions you could imagine another developer asking if they were sat beside you. You’ll save time in code reviews, and you’ll save time in the future, since you’ll be able to understand the context of that code, the choices and tradeoffs that were made while writing it – whe why of it – much faster than otherwise.

Here’s a great talk that further illustrates the value of good commit messages, and good commit hygiene in general: A Branch in Time by Tekin Süleyman.

  1. I met Glenn once, at a dinner during the Ruby Fools conference (such a long time ago now). He said some very nice things to me over that dinner, which I’ve never forgotten. So thanks for that, Glenn!