Safeguarding changes using update plans

By
on

At the company I’m currently working at, a.k.a. re:cap, we have some manual data imports that are business-critical, e.g., a customer-provided CSV file that we want to import into our database and that affects business operations. A simple pattern I came to love to make this process fast & safe is using update plans.

What are update plans?

Update plans are a way to preview changes before they are applied.

If you know Terraform, you already know update plans. Terraform is a tool that is used to manage infrastructure using a declarative programming language. Changing infrastructure is extremely delicate, remove one line of code, and the production server could be deleted, increasing the avg. heart rate of the team by 50 BPM. To safeguard our servers and heart rates, an infrastructure update is typically done with the following steps:

  1. The developer changes the infrastructure code as they desire, e.g., increasing the memory of a server from 8 to 16 GB.
  2. The developer executes the command "terraform plan". This will compare the wanted changes to what is currently active. The result is a plan on “how to move the current state to the desired state”.
  3. The developer verifies that the changes look sound.
  4. The developer executes "terraform apply" to actually apply the changes.

The key safeguard is that Terraform doesn’t directly apply the changes but first creates a plan to transition the current state to the desired one, allowing the user to review and approve the plan. This ensures that the developer can verify the changes are as intended, catching any undesired effects early.1

Some more examples:

  • Checkout pages are basically an update plan - they show you what you’ll buy, the amount of money to be deducted, where to send it, etc. One can read through all this before hitting the "buy" (or rather "apply") button.
  • A git diff is an update plan - it shows you the changes that you are about to commit.
  • A file deletion dialog is an update plan - it often shows how many files will be deleted and asks you to confirm.

Update plans for database changes

Not every update is as important as updating your infrastructure and needs an explicit plan & apply step. But the pattern to split important changes into a plan & apply step is useful not only in infrastructure management but in all cases where one can preview what the final changes will look like. Database updates fit this very well, and I will show you how this can look like.

An example: say you are working on the system for a supermarket chain and the prices for each product are shown on a tiny screen that is controlled by a centralized server, which gets the prices from a database. The prices are updated quite often, but sadly still in a manual way as the supermarkets’ fast paced environment never allowed them to address tech debt. To update the prices, someone has to upload a CSV file with two columns: product_id, price. Let’s model this with an update plan to make sure that the price for frozen pizza is always correct. I’ll use Go in the following example, because it's a great language.

First, we define our product data model: a simple ID & price combination. Before people scream that one should never use a float for a monetary value, we will use an int that represents "money" in cents.2

type Product struct {
	ID    string
	Price int
}

Then we define how our ProductPlan is supposed to look. Our update plan has:

  • Added - products that are only in the new dataset and thus will be added.
  • NoChanges - products that are both in the old and new datasets, but the price did not change.
  • Updated - products that are both in the old and new datasets, but the price changed.
  • Removed - products that are only in the old dataset and thus will be removed.
type ProductPair struct {
	OldProduct Product
	NewProduct Product
}

type ProductUpdatePlan struct {
	Added     []Product
	NoChanges []Product
	Updated   []ProductPair
	Deleted   []Product
}

The CreatePlan function then can look as follows. The cool thing here is that it’s a pure function; it has no side effects and doesn’t even return an error. It’s very easy to test this.

func CreatePlan(
	oldProducts []Product, newProducts []Product,
) ProductUpdatePlan {
	plan := ProductUpdatePlan{}
	oldProductMap := make(map[string]Product)
	for _, product := range oldProducts {
		oldProductMap[product.ID] = product
	}
	newProductMap := make(map[string]Product)
	for _, product := range newProducts {
		oldProduct, ok := oldProductMap[product.ID]
		if !ok {
			plan.Added = append(plan.Added, product)
		} else if oldProduct.Price != product.Price {
			plan.Updated = append(plan.Updated, ProductPair{
				OldProduct: oldProduct,
				NewProduct: product,
			})
		} else {
			plan.NoChanges = append(plan.NoChanges, product)
		}
		newProductMap[product.ID] = product
	}

	for _, product := range oldProducts {
		_, ok := newProductMap[product.ID]
		if !ok {
			plan.Deleted = append(plan.Deleted, product)
		}
	}

	return plan
}

To actually apply these changes, we define an interface that will represent the database. We keep it simple and do not use any batch updates/inserts here.

type Repo interface {
	InsertProduct(product Product) error
	UpdateProduct(product Product) error
	DeleteProduct(product Product) error
	GetProducts() ([]Product, error)
	Transaction(func(repo Repo) error) error
}

func (pup ProductUpdatePlan) Apply(repo Repo) error {
	return repo.Transaction(func(repo Repo) error {
		for _, product := range pup.Added {
			err := repo.InsertProduct(product)
			if err != nil {
				return err
			}
		}
		for _, productPair := range pup.Updated {
			err := repo.UpdateProduct(productPair.NewProduct)
			if err != nil {
				return err
			}
		}
		for _, product := range pup.Deleted {
			err := repo.DeleteProduct(product)
			if err != nil {
				return err
			}
		}
		return nil
	})
}

The overall flow could look like this - this could be the function an endpoint would call; the preview flag controls if it’s just a preview or the actual import.

func UpdateProducts(
	repo repo, csv string, preview bool
) (ProductUpdatePlan, error) {
	// Implementing parseCSV is left as an exercise for the reader.
	products, err := parseCSV(csv)
	if err != nil {
		return ProductUpdatePlan{}, err
	}
	oldProducts, err := repo.GetProducts()
	if err != nil {
		return ProductUpdatePlan{}, err
	}
	plan := CreatePlan(oldProducts, products)
	if preview {
		return plan, nil
	}

	return plan, plan.Apply(repo)
}

With this, we can offer a user experience that feels safe and predictable. The user can clearly see how their changes will interact with the system.

Interactive example

To have an excuse for building this blog with React.js, here is an interactive toy example of how this could look. You can change the provided CSV data, click "Preview changes" and see the update plan. You can then apply the update plan to the database by clicking "Apply changes". The products and CSV data are seeded with some sensible data. There is no error handling, you have to rely on the update plan for the safe guarding ;).

Products in the database

ID
Price
coffee
8.00
apple
1.00
banana
0.60
milk
1.50
bread
2.00
cheese
4.00
tomato
0.70
lettuce
0.80
cucumber
0.60
chicken breast
9.00
ground beef
7.00
pasta
1.60
rice
1.25
canned beans
1.30
cereal
2.50
peanut butter
3.50
jam
3.00
flour
1.00
sugar
0.90
eggs
2.00
orange juice
2.50
water bottle
0.50

CSV Data (you can change me!)

Update Plan

No update plan available.

When should one use update plans?

This pattern does not make sense for most data updates; as this entails more work and complexity. It makes sense when:

  • You’re updating something that is hard to grasp, but doing the update creates immediate negative effects.
  • The updates matter, and it’s not trivial to revert them.
  • It would help the user be more confident and be faster in checking that what they are about to do is correct, e.g., wouldn’t it be nice if email clients would tell you how many people you are about to send an email to if you are sending it to a group?

Snapshots & undo

Update plans do not prevent a faulty update from being applied, so it is important as well that there is an undo option. Sometimes this is not completely possible - e.g., when Terraform deletes your production database, you can’t just "undo" that.3 But in many cases, it is possible. E.g. snapshot the database before the update and then revert to that snapshot if the update was faulty.

Some update plans can actually be used to implement this feature, e.g., git commits are exactly that. Git commits are update plans that you can freely undo and redo.4 This works only so long as all changes to the system are made through the update plan, so for most systems, this is not feasible.

Why is this not a database feature already?

Personally I'm using this feature mostly with database updates and would really like this to be a first class citizen from them. I asked /r/Database if anybody knows of some database features that could enable something like this. Here is the thread if you are interested, my conclusion:

  • DoltDB basically supports this OOTB with its ability to create branches and native diff support. I find this project extremely intriguing personally, effortlessly creating snapshots is a game changer (I'm not affiliated with them).
  • Traditional databases do not have good support for this use case, CDC was suggested multiple times, but that only helps you after the commit. One could do scd2 (append only, you can query any version of the data) or something similar (append only, but clean up afterwards). This moves the complexity into the data model, which I'm personally not a fan of.

For me the application logic approach I showed above is the best for me personally as it is agnostic to whatever technology you use to actually save the data to and only entails moving around application logic.

Footnotes

  1. For Terraform, this is not perfect, sadly. There are often “noise” changes due to e.g., AWS API changes etc., that actually don’t change anything. And this is actually a difficult part of writing update plans - to correctly assess an "update" and a "no change".
  2. And you should never, I’ll haunt you personally. Floats are not precise enough for monetary values or anything really where precision is important. I find that representing money as fraction of a cent is a decent way (e.g. stripes API does this), normally I use a decimal type, those are sadly not built into programming languages, but there are libraries e.g. in Go there is the github.com/shopspring/decimal package, JavaScript has the big.js package.

  3. If you manage your AWS database using Terraform, please make sure you have "skip_final_snapshot" set to false as deleting a database also deletes the backups. Ask me how I know that.
  4. Git’s UX is just a bit lacking; while it is easy enough to undo a commit, it becomes much harder to undo e.g. a rebase. Git reflog exists, but it’s not a user friendly interface.