Azure's DocumentDB allows something nowadays that is not trivial with e.g. Azure SQL Database. It allows us to create a global database, all with one connection string. With Azure SQL it is also not difficult to create a geo-replicated database, but you have to use different connection strings and manage fail-over yourself.
To get started, you have to create a Document DB - Multi-Region Database Account.
After creating the account, you can add more regions very easily.
Default consistency level
A very important setting you should consider is the default consistency level. There are four options:
- Strong: Reads always see the most recent write. Only works for single-region!
- Bounded staleness: Allows read operations to read old versions, but only to a specified boundary
- Session: A connected session will see its own writes. Offers good performance.
- Eventual: Changes are seen "eventually". Highest performance option.
For a global DocumentDB, Strong is not an option. Most applications would probably choose Bounded staleness or Session. Choose Bounded Staleness if you want a stronger consistency. Otherwise, use Session.
Write region priority
This is another thing you can set on the database. It defines which region is the writable primary. Try to choose the region where you get the most traffic.
Demo setup
For this demo, I set the consistency to Session. I also deployed the DocumentDB to North Europe and East US in addition to West Europe.
I then created an ASP.NET MVC application in Visual Studio that allow me to write and read documents. First you must install the NuGet package Microsoft.Azure.DocumentDB.
Here is the model I'll be using:
using Microsoft.Azure.Documents;
using Newtonsoft.Json;
namespace JoonasDocDbDemo.Models
{
public class Employee
{
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("firstName")]
public string FirstName { get; set; }
[JsonProperty("lastName")]
public string LastName { get; set; }
[JsonProperty("jobTitle")]
public string Position { get; set; }
public static explicit operator Employee(Document doc)
{
Employee emp = new Employee();
emp.Id = doc.Id;
emp.FirstName = doc.GetPropertyValue<string>("firstName");
emp.LastName = doc.GetPropertyValue<string>("lastName");
emp.Position = doc.GetPropertyValue<string>("jobTitle");
return emp;
}
}
}
Note the function to cast a Document to an Employee. Someone on Stackoverflow mentioned I should be able to just have Employee inherit from Document, and that would work. It however did not. The other option was to define this.
Then, a utility class for ensuring the database and necessary collections exist. This is run from Global.asax.cs on start-up.
using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Client;
using System;
using System.Configuration;
using System.Threading.Tasks;
namespace JoonasDocDbDemo
{
public static class DocDb
{
private static readonly string DatabaseId = ConfigurationManager.AppSettings["docDb:DbId"];
private static readonly string CollectionId = ConfigurationManager.AppSettings["docDb:CollectionId"];
private static readonly string Endpoint = ConfigurationManager.AppSettings["docDb:Endpoint"];
private static readonly string AuthKey = ConfigurationManager.AppSettings["docDb:Key"];
public static async Task EnsureExistsAsync()
{
var client = new DocumentClient(new Uri(Endpoint), AuthKey);
await EnsureDatabaseExistsAsync(client);
await EnsureEmployeeCollectionExistsAsync(client);
}
private static async Task EnsureDatabaseExistsAsync(DocumentClient client)
{
try
{
await client.ReadDatabaseAsync(UriFactory.CreateDatabaseUri(DatabaseId));
}
catch (DocumentClientException e)
{
if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CreateDatabaseAsync(new Database { Id = DatabaseId });
}
else
{
throw;
}
}
}
private static async Task EnsureEmployeeCollectionExistsAsync(DocumentClient client)
{
try
{
await client.ReadDocumentCollectionAsync(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId));
}
catch (DocumentClientException e)
{
if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await client.CreateDocumentCollectionAsync(
UriFactory.CreateDatabaseUri(DatabaseId),
new DocumentCollection { Id = CollectionId },
new RequestOptions { OfferThroughput = 1000 });
}
else
{
throw;
}
}
}
}
}
The configuration settings look like this in Web.config:
<add key="docDb:DbId" value="EmployeeDb" />
<add key="docDb:CollectionId" value="Employees" />
<add key="docDb:Endpoint" value="https://joonasdocdemo.documents.azure.com:443/" />
<add key="docDb:Key" value="<primary key redacted>" />
I then created a service layer for interacting with the database:
using System;
using System.Collections.Generic;
using JoonasDocDbDemo.Models;
using System.Configuration;
using Microsoft.Azure.Documents.Client;
using System.Threading.Tasks;
using Microsoft.Azure.Documents.Linq;
using Microsoft.Azure.Documents;
using System.Net;
namespace JoonasDocDbDemo.Services
{
class EmployeeService : IEmployeeService
{
private static readonly string DatabaseId = ConfigurationManager.AppSettings["docDb:DbId"];
private static readonly string CollectionId = ConfigurationManager.AppSettings["docDb:CollectionId"];
private static readonly string Endpoint = ConfigurationManager.AppSettings["docDb:Endpoint"];
private static readonly string AuthKey = ConfigurationManager.AppSettings["docDb:Key"];
private static Uri CollectionUri = UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId);
private readonly DocumentClient client;
public EmployeeService()
{
client = new DocumentClient(new Uri(Endpoint), AuthKey);
}
public async Task<IList<Employee>> GetAllEmployees()
{
IDocumentQuery<Employee> query =
client.CreateDocumentQuery<Employee>(CollectionUri).AsDocumentQuery();
var results = new List<Employee>();
while (query.HasMoreResults)
{
results.AddRange(await query.ExecuteNextAsync<Employee>());
}
return results;
}
public Task CreateEmployee(Employee model)
{
return client.CreateDocumentAsync(CollectionUri, model);
}
public async Task<Employee> FindEmployeeByIdAsync(string id)
{
try
{
Document document =
await client.ReadDocumentAsync(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, id));
return (Employee)document;
}
catch (DocumentClientException e)
{
if (e.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
else
{
throw;
}
}
}
public async Task UpdateEmployeeAsync(Employee model)
{
await client.ReplaceDocumentAsync(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, model.Id), model);
}
public async Task DeleteEmployeeAsync(string id)
{
await client.DeleteDocumentAsync(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, id));
}
}
}
Here is the controller I made (gotta love C# 6 :)):
using JoonasDocDbDemo.Models;
using JoonasDocDbDemo.Services;
using System.Threading.Tasks;
using System.Web.Mvc;
namespace JoonasDocDbDemo.Controllers
{
public class EmployeeController : Controller
{
private readonly IEmployeeService service;
public EmployeeController()
{
service = new EmployeeService();
}
[HttpGet]
public async Task<ActionResult> Index()
=> View(await service.GetAllEmployees());
[HttpGet]
public ActionResult Create()
=> View(new Employee());
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Create(Employee model)
{
if (ModelState.IsValid == false)
{
return View(model);
}
await service.CreateEmployee(model);
return RedirectToAction(nameof(Index));
}
[HttpGet]
public async Task<ActionResult> Edit(string id)
=> View(await service.FindEmployeeByIdAsync(id));
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(Employee model)
{
if (ModelState.IsValid == false)
{
return View(model);
}
await service.UpdateEmployeeAsync(model);
return RedirectToAction(nameof(Index));
}
[HttpGet]
public async Task<ActionResult> Delete(string id)
=> View(await service.FindEmployeeByIdAsync(id));
[HttpPost]
[ValidateAntiForgeryToken]
[ActionName("Delete")]
public async Task<ActionResult> PostDelete(string id)
{
await service.DeleteEmployeeAsync(id);
return RedirectToAction(nameof(Index));
}
[HttpGet]
public async Task<ActionResult> Details(string id)
=> View(await service.FindEmployeeByIdAsync(id));
}
}
Publishing and results
I published the app to North EU, West EU, and East US data centers. I then created some employees, updated and deleted them.
I also checked the other regions if they got the updates. First I had the default consistency set to Session, but then tried what happens with the other ones:
- Session: Everything works perfectly, actually updates were immediately visible in all regions.
- Bounded Staleness: Everything worked perfectly, but I guess it could break with enough load.
- Eventual: Things started breaking, it would no longer find the entities it just created.
In the case of an app like this, Session consistency works best. Since otherwise the employee the user just created might not be visible after creating it otherwise. If your app can live with lower consistency, then choose one of the other ones.
Now my app was not designed to handle eventual consistency, which lead to it not working.
But you should remember it is possible to set the required consistency on a request-basis. Now it just used the default consistency I defined in the portal since I did not specify otherwise.
Thoughts
Honestly setting this up was a bit too easy. I'm seriously considering using DocumentDB in future projects.
I do have a couple complaints though:
- I can't run it on my dev box. We have to create one for each developer working on the project.
- Collection maximum size is 10 GB which can be enough for a lot of apps, but it can cause problems.
- Why is there no
ReadDocumentAsync<T>
function on the DocumentClient? Now I have to either first cast to dynamic and then to my type, or implement the explicit cast operator that you see above. This typed function could just callJsonConvert.DeserializeObject<T>()
right?
You can find the official documentation for multi-region DocumentDBs here.