Couch/Mongo
Playback speed

Created by: Kharanshu on GitHub, Kharanshu on LinkedIn, Prerna on LinkedIn

CouchDB3.4.3versus MongoDB8.3.8

Loading 9,091 rows of FAO crop data. Real time.

…
…

Two document databases, one crop dataset, six experiments. Every number here was measured on 2026-09-07 and plays back at the speed it happened.

A bar chart would have fitted CouchDB to the axis. We made you wait for it instead.

Bar charts are polite. They scale the loser to fit and let a thirty-fold gap read as a slightly longer rectangle. On this page a counter runs for as long as the database did, then stops. Pick a continent at the top and every race reruns on that continent's numbers. Anything over four seconds is played faster and says so on the line beneath.

Numbers are from the 2026 re-run (2026-09-07, CouchDB 3.4.3, MongoDB 8.3.8). The observations folded under each experiment are quoted from the original 2024 report.

What CouchDB wins

  • Memory. It loaded the continent in a third of the resident memory MongoDB used for the same rows.
  • Watching for changes. One HTTP request, no replica set, no config file.
  • Reach. Anything that can speak HTTP is already a client. No driver.
  • Replication and offline sync. Built in, not bolted on. If your problem is many devices and eventual consistency, start here.

What MongoDB wins

  • Every stopwatch. 9× on the bulk load, 5× one document at a time, 32× on the whole-collection update.
  • Disk. The same rows in two-thirds of the space.
  • Fewer moving parts. An aggregate without writing a map and a reduce. An update without a round trip. Validation and a user account are one command each.
  • Answers. Maintained drivers, current documentation, and a community that has already hit your bug.

Dataset and method

FAO crop production statistics, 1961 to 2019, one CSV per continent. Each row is a country, a crop and one of three measures (area harvested, yield, production), followed by 59 yearly values and 59 flag columns: 125 fields per row. Africa alone is 9,091 rows; all five continents are 38,146. The 2024 project used Africa only; the re-run covers every continent.

Every row becomes one document, values kept as the strings they are in the CSV, so the update experiment has real work to do. Each operation is timed from the client after one warm-up run, and the median of 3 runs is what plays back. Server CPU and memory come from the database container's cgroup counters, sampled before and after each operation, so they describe the database process rather than the Python client. Both databases run in Docker on the same machine (20 cores, 32 GB), one after the other, never at the same time.

The one-at-a-time upload stops at 1,000 documents. The point of that experiment is the per-request cost, and a thousand requests shows it.

1Uploading records

Bulk insert of a whole continent, the same with a caller-supplied _id, and one document per request. Then what it cost the server.

Bulk load

…
…

Bulk load with our own _id

…
…

One document per request, 1,000 documents

…
…

On disk after the load

…
…

Server memory after the bulk load

…
…

Server CPU during the bulk load

…
…

CouchDB
PUT  /cvm_africa                # the database must exist first
POST /cvm_africa/_bulk_docs      # 2,000 rows per request
{"docs": [{"area": "Algeria", "item": "Apples", …}, …]}
POST /cvm_africa                 # one document per request
{"area": "Algeria", "item": "Apples", …}
MongoDB
col = MongoClient(url)["cvm"]["africa"]   # created on first write
col.insert_many(docs, ordered=False)
for d in docs:                            # one document per request
    col.insert_one(d)
What the 2024 run found
  • Importing the CSV straight into MongoDB Compass used the least memory and time of any path.
  • CouchDB refuses to insert into a database that does not exist; MongoDB creates it on first write.
  • CouchDB used less memory and CPU than MongoDB during the JSON upload, but took longer.
  • The first upload into a fresh CouchDB took 30 to 40 seconds longer while it built its index; MongoDB does not build one on insert.
  • MongoDB's collection was a fraction of the size of the CouchDB database holding the same rows.

2Retrieving and updating

A filter on one field with and without an index, an aggregate (total production of two crops in one country in 2005), and a whole-collection update that turns the y2005 string into a number and blanks into zero.

Filter on one field, no index

…
…

The same filter, indexed

…
…

Aggregate, first hit, view or pipeline built cold

…
…

Aggregate, warm

…
…

Rewrite y2005 in every document

…
…

Server CPU during the update

…
…

CouchDB
POST /cvm_africa/_find            # Mango; limit defaults to 25
{"selector": {"area": "Algeria"}, "limit": 1000000}
POST /cvm_africa/_index
{"index": {"fields": ["area"]}, "name": "area"}
PUT  /cvm_africa/_design/agg      # aggregation = a view
{"views": {"total": {
  "map": "function(d){ if(d.area==='Algeria' && d.element==='Production'
           && ['Apples','Apricots'].indexOf(d.item)>=0)
           emit('total', parseFloat(d.y2005)||0); }",
  "reduce": "_sum"}}}
GET  /cvm_africa/_design/agg/_view/total
# update: no server-side $set, so fetch, change, write back
GET  /cvm_africa/_all_docs?include_docs=true
for d in docs: d["y2005"] = float(d["y2005"] or 0)
POST /cvm_africa/_bulk_docs  {"docs": docs}
MongoDB
col.find({"area": "Algeria"})
col.create_index("area")
col.aggregate([
  {"$match": {"area": "Algeria", "element": "Production",
              "item": {"$in": ["Apples", "Apricots"]}}},
  {"$group": {"_id": None, "total": {"$sum": {"$toDouble":
      {"$cond": [{"$eq": ["$y2005", ""]}, "0", "$y2005"]}}}}}
])
col.update_many({}, [{"$set": {"y2005": {"$toDouble":
    {"$cond": [{"$eq": ["$y2005", ""]}, "0", "$y2005"]}}}}])
What the 2024 run found
  • The MongoDB update finished in seconds from the shell inside Compass. CouchDB has no update endpoint, so the change ran as a Python loop over every document and took far longer.
  • A single-field filter was direct in both. Compass shows the result as a table; Fauxton shows JSON and stores the query as a view document in the database.
  • Aggregates are a pipeline in MongoDB. In CouchDB they mean writing a map function and a reduce function into a design document, and the reduce has to be switched on by hand every time the view is opened.

3Validating documents

A rule that element must be one of three values. Then one good insert and one bad one.

Install the rule, try the bad write and the good one

…
…

CouchDB
PUT /cvm_africa/_design/validate
{"validate_doc_update": "function(newDoc, oldDoc, userCtx) {
  if (newDoc._deleted) return;
  if (['Area harvested','Production','Yield'].indexOf(newDoc.element) < 0)
    throw({forbidden: 'element must be one of Area harvested, Production, Yield'});
}"}
# bad insert → 403 forbidden. Existing documents are never re-checked.
MongoDB
db.command("collMod", "africa",
  validator={"$jsonSchema": {
    "required": ["element"],
    "properties": {"element": {"enum": ["Area harvested", "Production", "Yield"]}}}},
  validationLevel="strict",      # or "moderate": skip already-invalid docs
  validationAction="error")      # or "warn": log it, let it through
# bad insert → DocumentValidationFailure
What the 2024 run found
  • Both enforce the rule; MongoDB's is a form in Compass, CouchDB's is a JavaScript function you write.
  • MongoDB can check documents already in the collection; CouchDB only ever checks new writes.
  • MongoDB adds a choice of action, error or warning, and a level, strict or moderate. CouchDB has one behaviour: reject.

4Users and access

Create a read-only user, then have that user try to write. This one is about steps, not milliseconds: three on the left, one on the right.

CouchDB
# 1. the user is a document
PUT /_users/org.couchdb.user:alice
{"name": "alice", "password": "…", "roles": ["reader"], "type": "user"}
# 2. let the role into the database
PUT /cvm_africa/_security
{"members": {"roles": ["reader"]}, "admins": {"roles": []}}
# 3. members can write, so a rule has to say no
PUT /cvm_africa/_design/readonly
{"validate_doc_update": "function(newDoc, oldDoc, userCtx) {
  if (userCtx.roles.indexOf('_admin') < 0)
    throw({unauthorized: 'read only'}); }"}
MongoDB
# mongod.conf
security:
  authorization: enabled
# mongosh, as the admin
db.createUser({
  user: "alice", pwd: "…",
  roles: [{ role: "read", db: "cvm" }]
})
# alice's insert → "not authorized on cvm to execute command"
What the 2024 run found
  • MongoDB is one command with a long list of built-in roles. CouchDB is a user document, a security object per database, and a validation function to make "reader" actually mean read-only.
  • Only admin-level accounts can change permissions in either system, and both hash stored passwords.
  • A change made by one user is visible to another straight away in both.

5Watching for changes

A listener tails the change feed while 100 documents are inserted. How many arrive, and how long after the insert.

Insert to listener, median of 100

…
…

CouchDB
GET /cvm_africa/_changes?feed=continuous&since=<update_seq>&heartbeat=500
# one JSON line per change, forever:
{"seq": "9094-g1AAAA…", "id": "watch_0", "changes": [{"rev": "1-8f3…"}]}
# resume by passing the last seq as since. Anyone who can read the
# database can read the feed.
MongoDB
# needs a replica set, even a single node
mongod --replSet rs0
rs.initiate()
with col.watch([{"$match": {"operationType": "insert"}}]) as stream:
    for change in stream:
        print(change["fullDocument"])
# resume with resume_after=change["_id"]; filter by any field;
# only users with the changeStream privilege can open one.
What the 2024 run found
  • CouchDB's feed is one API call. MongoDB's needs the config file edited, a replica set created and the oplog enabled before a stream can open.
  • That ease cuts the other way: the CouchDB feed is visible to anyone who can read the database, while MongoDB limits streams to accounts with the right privilege.
  • MongoDB streams survive rollbacks, can resume from a token and can filter on operation type or field. The CouchDB feed has none of those.

6Working with other tools

Pulling the data into R for a chart, and checking what else can connect.

CouchDB
install.packages("R4CouchDB")   # last release 2017
library(R4CouchDB)
cdb <- cdbIni(uname = "admin", pwd = "…")
cdb$newDBName <- "r-example"
cdb <- cdbMakeDB(cdb)
# Python: no driver needed, it is HTTP
requests.post(f"{url}/cvm_africa/_find", json={"selector": {…}})
MongoDB
install.packages("mongolite")   # maintained
library(mongolite)
crops <- mongo(collection = "africa", db = "cvm",
               url = "mongodb://localhost:27017")
df <- crops$find('{"element": "Production"}')
# Python: pymongo
MongoClient(url)["cvm"]["africa"].find({…})
What the 2024 run found
  • Both have an R package. mongolite is maintained; R4CouchDB has not been updated since 2017.
  • Power BI connects to MongoDB Atlas directly and to a local server through MongoDB's own connector. There is no CouchDB connector, paid or free.
  • In Python, pymongo offers more than raw HTTP does, but CouchDB needs no library at all.

Side by side

The 2024 report's comparison, feature on the seam. Footnotes mark where the picture has moved by 2026.

CouchDBFeatureMongoDB
Needs JSON. Documents are shown as JSON objects.Source data and presentationImports CSV and JSON directly. Documents are shown as a table.
JSON documents; strings, numbers, arrays, objects, booleans. Larger on disk. Validation applies to new writes only.Data model and sizeBSON documents with many more types, smaller on disk. Validation can cover existing records or only new writes.
Not fully ACID1; favours availability and partition tolerance.ACID and CAPACID transactions; favours consistency and partition tolerance.
Limited indexing2.IndexingCompound, text, geo, TTL, partial, wildcard and compound-wildcard indexes.
Retrieval through Mango queries; aggregation means map/reduce views.CRUDAll of it from the Compass interface or the shell, aggregation pipelines included.
Admins define users, roles and the validation rules that enforce them. Eventually consistent; changes reach replicas asynchronously.Users and consistencyCreating a user is one step with many predefined roles. Well-defined concurrency control across users.
One API call.Change monitoringChange streams, replica set required.
Reachable from any language that speaks HTTP. Third-party libraries and connectors are mostly stale; documentation is sparse3.Tools, libraries, documentationOfficial drivers for many languages, regularly updated. Thorough documentation and an active community.
  1. CouchDB is ACID for a single document (every write is atomic and durable); it has no multi-document transactions. The 2024 wording is kept as written.
  2. CouchDB 3.x has Mango JSON indexes (including partial indexes) and text indexes when built with search. Compound, geo and TTL are still missing, and nothing is indexed automatically except _id.
  3. The Apache docs at docs.couchdb.org are current for 3.4; the "sparse" finding was about third-party guides and the R package.

Every number

Pick a measurement and see it for every continent at once. The continent chosen at the top is drawn solid, the rest faded. Medians of 3 runs.

The same as a table, every measurement, for the continent picked at the top

What we would do differently

  • Single runs are noise. The 2024 timings were one run each, cold. The re-run warms up once and reports the median of three, and still reports the cold number separately because it is real too.
  • The GUI was in the way. Compass and Fauxton do their own work on top of the database, and Task Manager cannot separate the two. Sampling the container's cgroup counters measures only the server.
  • Fair baselines. The Mango query was run without an index and compared with a MongoDB find that also had none, then both again with one. Comparing an indexed query with an unindexed one says nothing about the database. One thing is still not equal: MongoDB acknowledges a bulk insert once it is in memory and journals on its own cycle, CouchDB syncs to disk before it answers, so the bulk-load gap includes that. The warm aggregate compares a persisted CouchDB view with a MongoDB pipeline that recomputes.
  • CouchDB's update was done the slow way. Saving each document in a loop is the worst path; the re-run fetches everything once and writes it back once with _bulk_docs. It is still a round trip, which is the point, but a fair one.
  • One continent is a small sample. All five are in now, and the picture holds across them.
  • Same box, one at a time. Running both databases together on a laptop would have them fighting for the same cores. They run in sequence.

Conclusion

Both are document databases, both take semi-structured data without a master schema, and both were happy to hold 38,000 rows of crop statistics. They diverge on how much of the work they do for you. MongoDB imports a CSV, creates the collection, rewrites a field, aggregates, validates and adds a user with one command each. CouchDB gives you HTTP and JSON and expects you to build the rest: design documents for aggregation and validation, a round trip for every update, a security object plus a function for every role.

For someone learning or deploying a document database in 2026 we still recommend MongoDB: easier to learn, faster on this workload, smaller on disk, and with a community that will answer the question you are about to have. Choose CouchDB when its shape is your shape: replication, offline-first clients, and a change feed that any HTTP client can follow.

Reproduce it

Everything on this page is produced by one Python file against two containers. The README, the data notes and the four files you need are printed in full below, each with a copy button. The five CSVs come from data.world/agriculture/crop-production. Docker and Python 3.12 or newer do the rest.

mkdir couch-vs-mongo && cd couch-vs-mongo   # save the four files below here
mkdir data                     # and the five production_crops_e_<continent>.csv files in data/
cp env.example .env            # CouchDB admin password; change it
docker compose up -d --wait    # mongo:8 and couchdb:3.4, on localhost only
pip install -r requirements.txt
python bench.py all            # about 30 minutes; writes results.json and the explorer data

Run a single experiment with python bench.py load --db couch --continent africa --runs 5. Your numbers land in results.json and in a site folder the run creates: save this page and its files into that folder and the races play your run instead.

README.md, 86 lines
# CouchDB vs MongoDB

Six experiments on one FAO crop-production dataset: bulk and single-document upload, queries and a
whole-collection update, document validation, users and access, change feeds, and tooling.
A Big Data 5410H course project (April 2024) rebuilt in 2026 so every number is reproducible and the
results page plays them back as timed races instead of bar charts.

Everything needed to run it lives in this folder: the code, the five CSVs, the database files, and a
tarball of the two Docker images. Nothing is fetched from the network at run time; the site loads its
font and Chart.js from its own files.

## Requirements

- Docker Desktop (Docker 27 or newer with `docker compose`)
- Python 3.12 or newer
- Node 22 or newer, only for the page's unit tests

## Run it

```
cp .env.example .env                       # CouchDB admin password; also read by bench.py
docker load -i docker/images.tar.gz        # optional: restores mongo:8 and couchdb:3.4 offline
docker compose up -d                       # mongo:8 (single-node replica set) + couchdb:3.4
python -m venv .venv && .venv/Scripts/pip install -r requirements.txt   # Linux/macOS: .venv/bin/pip
.venv/Scripts/python bench.py all          # about 30 minutes: every experiment x every continent
python -m http.server -d site              # open http://localhost:8000
```

Skip `docker load` when the machine is online; `docker compose up` pulls the same two images.
Both databases listen on localhost only.

Single pieces:

```
python bench.py load   --db couch --continent africa --runs 5
python bench.py query  --db mongo
python bench.py watch
python bench.py explore          # site/explore_<continent>.json for the data explorer
python bench.py selftest         # no Docker needed
node --test site/lib.test.mjs    # the page logic, 8 tests
```

Every subcommand merges into `results.json` and copies it to `site/`, so partial runs show partial
races.

## What is measured

- Wall-clock time from the client, median of `--runs` after `--warmup` runs; the first cold run is
  kept as `cold_ms`.
- Server CPU (% of one core) and memory (MB) read from the container's cgroup before and after each
  operation: the database process only, not the Python client.
- On-disk size after the bulk load with caller-supplied `_id`.
- Change-feed delivery: documents received of 100 inserted, and median insert-to-listener latency.
- Validation: did the rule reject the bad document and accept the good one.

The committed `results.json` was measured on 2026-09-07 with the database files in `dbdata/`.
MongoDB's memory column grows through a run because WiredTiger keeps its cache; CouchDB's does not.

## Layout

```
bench.py             the whole benchmark, one file
docker-compose.yml   the two databases, data bind-mounted from dbdata/
results.json         latest run (committed); site/results.json is a copy
site/                static page: index.html, style.css, app.js, lib.js + lib.test.mjs,
                     chart.umd.min.js (Chart.js 4.5.0), fonts/, results and explorer JSON
embed.py             pastes bench.py, compose, requirements and .env.example into the page's
                     Reproduce section; run it after editing any of them
data/                the five FAO CSVs, 38,146 rows (gitignored, see data/README.md)
dbdata/              MongoDB and CouchDB data directories (gitignored, ~750 MB)
docker/              images.tar.gz, both images for offline use (gitignored, ~450 MB)
.venv/               Python environment (gitignored)
.claude/skills/      mongodb (official), mongodb-expert, couchdb, hunt-dom + hunt-xss
```

`git clone` gives the code and results only. Copy `data/`, `dbdata/` and `docker/` alongside it for
the full offline package.

## Deploy

`site/` is static with no build step and no external requests. Upload the folder to any static host.

## Credits

Prerna Gurbaxani and Kharanshu Naghera, original project and report. Data from FAO via
[data.world/agriculture/crop-production](https://data.world/agriculture/crop-production).
data/README.md, 19 lines
# Data

FAO crop production statistics, 1961–2019, one CSV per continent:

```
production_crops_e_africa.csv
production_crops_e_americas.csv
production_crops_e_asia.csv
production_crops_e_europe.csv
production_crops_e_oceania.csv
```

Source: FAOSTAT "Crops and livestock products" as packaged on data.world
(https://data.world/agriculture/crop-production). Drop the CSVs in this folder
(gitignored, ~28 MB, 38,146 rows). `bench.py` reads them directly; no conversion step.

Columns: `area_code, area, item_code, item, element_code, element, unit`, then
`y1961 … y2019` values and `y1961f … y2019f` FAO flags (`F` estimate, `Fc` calculated,
`Im` imputed, `M` missing, blank = official).
env.example, 5 lines
# copy to .env — read by docker compose (CouchDB admin) and by bench.py (connection URLs)
COUCHDB_USER=admin
COUCHDB_PASSWORD=password
COUCH_URL=http://admin:password@localhost:5984
MONGO_URL=mongodb://localhost:27017/?directConnection=true
requirements.txt, 3 lines
pymongo>=4.10
requests>=2.32
psutil>=6
docker-compose.yml, 26 lines
services:
  mongo:
    image: mongo:8
    container_name: cvm-mongo
    ports: ["127.0.0.1:27017:27017"]
    # single-node replica set: change streams (experiment 5) need one.
    # No auth: local benchmark box only. bench.py runs rs.initiate() on first use.
    command: ["--replSet", "rs0", "--bind_ip_all"]
    volumes: ["./dbdata/mongo:/data/db"]   # data lives in the repo folder, not in a Docker volume
    healthcheck:   # so `docker compose up -d --wait` returns only once the DB answers
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
      interval: 5s
      timeout: 3s
      retries: 20

  couchdb:
    image: couchdb:3.4
    container_name: cvm-couch
    ports: ["127.0.0.1:5984:5984"]
    env_file: .env   # COUCHDB_USER / COUCHDB_PASSWORD (CouchDB 3 refuses to start without an admin)
    volumes: ["./dbdata/couch:/opt/couchdb/data"]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5984/_up"]
      interval: 5s
      timeout: 3s
      retries: 20
bench.py, 316 lines
#!/usr/bin/env python3
"""CouchDB vs MongoDB on the FAO crop-production CSVs. One file.

    docker compose up -d
    pip install -r requirements.txt
    python bench.py all                # every experiment, every continent -> results.json + site/
    python bench.py load --db couch --continent africa --runs 5
    python bench.py explore            # site/explore_<continent>.json for the data explorer
    python bench.py selftest
"""
import argparse, csv, json, os, shutil, statistics, subprocess, threading, time
from pathlib import Path

import psutil, requests
from pymongo import MongoClient
from pymongo.errors import WriteError

ROOT = Path(__file__).resolve().parent
DATA, SITE, RESULTS = ROOT / "data", ROOT / "site", ROOT / "results.json"
CONTINENTS = ["africa", "americas", "asia", "europe", "oceania"]
CONTAINER = {"mongo": "cvm-mongo", "couch": "cvm-couch"}
ONE_LIMIT = 1000   # one-by-one inserts stop here: 38K single HTTP posts is a lunch break, not a benchmark
CHUNK = 2000       # docs per CouchDB _bulk_docs request
ELEMENTS = ["Area harvested", "Production", "Yield"]
GOOD, BAD = {"area": "Test", "item": "Test", "element": "Yield"}, {"area": "Test", "item": "Test", "element": "Bogus"}
RUNS, WARMUP = 3, 1

for line in (ROOT / ".env").read_text().splitlines() if (ROOT / ".env").exists() else []:
    if "=" in line and not line.startswith("#"):
        k, v = line.split("=", 1); os.environ.setdefault(k.strip(), v.strip())
MONGO_URL = os.environ.get("MONGO_URL", "mongodb://localhost:27017/?directConnection=true")
COUCH_URL = os.environ.get("COUCH_URL", "http://admin:password@localhost:5984").rstrip("/")

# ---------------------------------------------------------------- data
def rows(continent):
    with open(DATA / f"production_crops_e_{continent}.csv", encoding="utf-8", newline="") as f:
        return [dict(r, continent=continent) for r in csv.DictReader(f)]   # values stay strings, like the 2024 run

def with_ids(docs, continent):
    return [dict(d, _id=f"{continent}_{i}") for i, d in enumerate(docs)]

def pick(base):
    """(area, [item, item]) for the query experiments: first area with two crops that have production data."""
    for area in dict.fromkeys(r["area"] for r in base):
        items = list(dict.fromkeys(r["item"] for r in base if r["area"] == area and r["element"] == "Production" and r["y2005"]))
        if len(items) >= 2:
            return area, items[:2]
    return base[0]["area"], []   # nothing usable: the queries would run empty, but do not crash the run

# ---------------------------------------------------------------- clients
mongo = MongoClient(MONGO_URL, serverSelectionTimeoutMS=5000)
couch = requests.Session()   # keep-alive, so HTTP gets the same connection reuse pymongo's pool gets

def mcol(c): return mongo["cvm"][c]
def cdb(c): return f"{COUCH_URL}/cvm_{c}"

def ensure_replset():
    try:
        mongo.admin.command("replSetGetStatus")
    except Exception:
        mongo.admin.command("replSetInitiate", {"_id": "rs0", "members": [{"_id": 0, "host": "localhost:27017"}]})
        deadline = time.time() + 60
        while not mongo.is_primary:
            if time.time() > deadline:
                raise RuntimeError("mongo did not become primary within 60s after replSetInitiate")
            time.sleep(0.5)

def reset(db, c):
    if db == "mongo":
        mcol(c).drop()
    else:
        couch.delete(cdb(c)); couch.put(cdb(c)).raise_for_status()

def versions():
    def v(fn):
        try:
            return fn()
        except Exception:
            return None   # a DB being down must not cost us the results file
    return {"mongo": v(lambda: mongo.server_info()["version"]), "couch": v(lambda: couch.get(COUCH_URL).json()["version"])}

# ---------------------------------------------------------------- measurement
def parse_cgroup(words):
    """cgroup v2 (`cpu.stat` + `memory.current`) or v1 (`cpuacct.usage` ns + `memory.usage_in_bytes`) -> (cpu_s, mem_mb)."""
    if "usage_usec" in words:
        return int(words[words.index("usage_usec") + 1]) / 1e6, int(words[-1]) / 2**20
    return int(words[0]) / 1e9, int(words[1]) / 2**20

def cgroup(db):
    """Cumulative CPU seconds and current memory of the DB container; (None, None) when docker is not around."""
    sh = ("cat /sys/fs/cgroup/cpu.stat /sys/fs/cgroup/memory.current 2>/dev/null"
          " || cat /sys/fs/cgroup/cpuacct/cpuacct.usage /sys/fs/cgroup/memory/memory.usage_in_bytes")
    try:
        out = subprocess.run(["docker", "exec", CONTAINER[db], "sh", "-c", sh], capture_output=True, text=True, timeout=10)
        return parse_cgroup(out.stdout.split())
    except Exception:
        return None, None

IDLE = {}          # CPU seconds one cgroup sample pair costs the container by itself (sh + cat), per db
CPU_MIN_S = 0.25   # below this the sampling overhead swamps the signal; cpu_pct is left out

def calibrate(db):
    if db not in IDLE and cgroup(db)[0] is not None:
        pairs = [(cgroup(db)[0], cgroup(db)[0]) for _ in range(3)]
        IDLE[db] = statistics.median(b - a for a, b in pairs)

def measure(db, fn, setup=None):
    """Median wall-clock ms of fn over RUNS after WARMUP; server CPU (% of one core) and RSS from the container cgroup."""
    ms, cpu, mem = [], [], []
    calibrate(db)
    for _ in range(WARMUP + RUNS):
        if setup: setup()
        c0, _ = cgroup(db)
        t0 = time.perf_counter(); fn(); t = time.perf_counter() - t0
        c1, m = cgroup(db)
        ms.append(t * 1000)
        if c0 is not None and c1 is not None:
            cpu.append(max(0.0, c1 - c0 - IDLE.get(db, 0.0)) / t * 100); mem.append(m)
        else:
            cpu.append(None); mem.append(None)   # keep the lists aligned with ms, the WARMUP slice depends on it
    r = {"ms": round(statistics.median(ms[WARMUP:]), 1), "cold_ms": round(ms[0], 1)}
    mem_ok, cpu_ok = [x for x in mem if x is not None], [x for x in cpu[WARMUP:] if x is not None]
    if mem_ok:
        r["mem_mb"] = round(max(mem_ok), 1)
        if cpu_ok and statistics.median(ms[WARMUP:]) >= CPU_MIN_S * 1000:
            r["cpu_pct"] = round(statistics.median(cpu_ok), 1)
    return r

# ---------------------------------------------------------------- experiments
def load(db, c, docs, mode="bulk"):
    if db == "mongo":
        if mode == "bulk":
            mcol(c).insert_many(docs, ordered=False)
        else:
            for d in docs: mcol(c).insert_one(d)
    elif mode == "bulk":
        for i in range(0, len(docs), CHUNK):
            couch.post(f"{cdb(c)}/_bulk_docs", json={"docs": docs[i:i + CHUNK]}).raise_for_status()
    else:
        for d in docs: couch.post(cdb(c), json=d).raise_for_status()

def size_mb(db, c):
    if db == "mongo":
        mongo.admin.command("fsync", lock=False)   # checkpoint, else storageSize is whatever the last one saw
        s = mongo["cvm"].command("collstats", c)
        return round((s["storageSize"] + s["totalIndexSize"]) / 2**20, 2)
    return round(couch.get(cdb(c)).json()["sizes"]["file"] / 2**20, 2)

def exp_load(db, c, base):
    out, batch = {}, []
    # order matters: the with-_id bulk load runs last and stays in place for the other experiments
    for key, docs, mode in [("upload_bulk", base, "bulk"), (f"upload_one_{ONE_LIMIT}", base[:ONE_LIMIT], "one"),
                            ("upload_bulk_id", with_ids(base, c), "bulk")]:
        def setup():
            reset(db, c); batch[:] = [dict(d) for d in docs]      # fresh copies: insert_many stamps _id into dicts
        out[key] = measure(db, lambda: load(db, c, batch, mode), setup)
    out["size_mb"] = size_mb(db, c)
    return out

def exp_query(db, c, base):
    area, items = pick(base)
    simple, out = {"area": area}, {}
    if db == "mongo":
        col = mcol(c)
        try:
            col.drop_index("area_1")   # a previous run's index would make the "unindexed" number a lie
        except Exception:
            pass
        out["query_simple"] = measure(db, lambda: list(col.find(simple)))
        col.create_index("area")
        out["query_simple_indexed"] = measure(db, lambda: list(col.find(simple)))
        pipe = [{"$match": {"area": area, "item": {"$in": items}, "element": "Production"}},
                {"$group": {"_id": None, "total": {"$sum": {"$toDouble": {"$cond": [{"$eq": ["$y2005", ""]}, "0", "$y2005"]}}}}}]
        out["query_aggregate"] = measure(db, lambda: list(col.aggregate(pipe)))
    else:
        find = lambda **kw: couch.post(f"{cdb(c)}/_find", json={"selector": simple, "limit": 10**6, **kw}).json()["docs"]
        for ix in couch.get(f"{cdb(c)}/_index").json()["indexes"]:   # same reason: leftover index from a previous run
            if ix["name"] == "area" and ix.get("ddoc"):
                couch.delete(f"{cdb(c)}/_index/{ix['ddoc']}/json/area")
        out["query_simple"] = measure(db, find)
        couch.post(f"{cdb(c)}/_index", json={"index": {"fields": ["area"]}, "name": "area"}).raise_for_status()
        out["query_simple_indexed"] = measure(db, lambda: find(use_index="area"))
        view = {"views": {"total": {"reduce": "_sum", "map":
                f"function(d){{ if(d.area==={json.dumps(area)} && d.element==='Production' && {json.dumps(items)}.indexOf(d.item)>=0)"
                f" emit('total', parseFloat(d.y2005)||0); }}"}}}
        couch.put(f"{cdb(c)}/_design/agg", json=view).raise_for_status()
        out["query_aggregate"] = measure(db, lambda: couch.get(f"{cdb(c)}/_design/agg/_view/total").json())
    return out

def exp_update(db, c, base):
    """Experiment 2b: y2005 string -> number, blanks -> 0, across the whole collection."""
    def setup():
        reset(db, c); load(db, c, with_ids(base, c))
    if db == "mongo":
        pipe = [{"$set": {"y2005": {"$toDouble": {"$cond": [{"$eq": ["$y2005", ""]}, "0", "$y2005"]}}}}]
        fn = lambda: mcol(c).update_many({}, pipe)
    else:
        def fn():   # CouchDB has no server-side update: fetch everything, change it, write it back
            docs = [r["doc"] for r in couch.get(f"{cdb(c)}/_all_docs", params={"include_docs": "true"}).json()["rows"]]
            for d in docs: d["y2005"] = float(d["y2005"] or 0)
            for i in range(0, len(docs), CHUNK):
                couch.post(f"{cdb(c)}/_bulk_docs", json={"docs": docs[i:i + CHUNK]}).raise_for_status()
    return {"update_y2005": measure(db, fn, setup)}

def exp_validate(db, c):
    t0 = time.perf_counter()
    if db == "mongo":
        mongo["cvm"].command("collMod", c, validator={"$jsonSchema": {"required": ["element"], "properties": {"element": {"enum": ELEMENTS}}}},
                             validationLevel="strict", validationAction="error")
        def put(d):
            try:
                mcol(c).insert_one(dict(d)); return True
            except WriteError:   # only the validator saying no; connection errors must still blow up
                return False
    else:
        fn = ("function(newDoc, oldDoc, userCtx){ if(newDoc._deleted) return;"
              f" if({json.dumps(ELEMENTS)}.indexOf(newDoc.element) < 0) throw({{forbidden: 'element must be one of {', '.join(ELEMENTS)}'}}); }}")
        couch.put(f"{cdb(c)}/_design/validate", json={"validate_doc_update": fn}).raise_for_status()
        put = lambda d: couch.post(cdb(c), json=d).status_code == 201
    return {"validation": {"accepted_good": put(GOOD), "rejected_bad": not put(BAD), "ms": round((time.perf_counter() - t0) * 1000, 1)}}

def exp_watch(db, c, n=100):
    """Experiment 5: insert n docs while a listener tails the change feed; report delivery count and median latency."""
    seen, stop = {}, threading.Event()
    def listen():
        if db == "mongo":
            with mcol(c).watch([{"$match": {"operationType": "insert"}}], max_await_time_ms=200) as stream:
                while not stop.is_set():
                    ch = stream.try_next()
                    if ch: seen[ch["fullDocument"]["_id"]] = time.perf_counter()
        else:
            since = couch.get(cdb(c)).json()["update_seq"]
            with requests.get(f"{cdb(c)}/_changes", params={"feed": "continuous", "since": since, "heartbeat": 500}, stream=True) as r:
                for line in r.iter_lines():
                    if stop.is_set(): break
                    if not line: continue
                    j = json.loads(line)
                    if "id" in j: seen[j["id"]] = time.perf_counter()   # the trailing last_seq line has none
    threading.Thread(target=listen, daemon=True).start(); time.sleep(1.5)   # let the listener attach
    sent = {}
    for i in range(n):
        d = dict(GOOD, _id=f"watch_{i}"); sent[d["_id"]] = time.perf_counter()
        mcol(c).insert_one(d) if db == "mongo" else couch.post(cdb(c), json=d)
    time.sleep(2); stop.set()
    lat = [(seen[k] - sent[k]) * 1000 for k in sent if k in seen]
    return {"watch": {"sent": n, "received": len(lat), "median_latency_ms": round(statistics.median(lat), 1) if lat else None}}

def explore(continent):
    """Production series per area x crop for the site's data explorer (all units are tonnes)."""
    years, areas = list(range(1961, 2020)), {}
    for r in rows(continent):
        if r["element"] == "Production":
            areas.setdefault(r["area"], {})[r["item"]] = [round(float(r[f"y{y}"])) if r[f"y{y}"] else None for y in years]
    return {"years": years, "unit": "tonnes", "areas": areas}

# ---------------------------------------------------------------- driver
EXPERIMENTS = {"load": exp_load, "query": exp_query, "update": exp_update, "validate": exp_validate, "watch": exp_watch}

def save(update):
    r = json.loads(RESULTS.read_text()) if RESULTS.exists() else {"meta": {}, "results": {}}
    for c, exps in update.items():
        for k, v in exps.items():
            r["results"].setdefault(c, {}).setdefault(k, {}).update(v)
    r["meta"] |= {"date": time.strftime("%Y-%m-%d"), "runs": RUNS, "warmup": WARMUP, "one_limit": ONE_LIMIT,
                  "rows": {c: len(rows(c)) for c in CONTINENTS}, "versions": versions(),
                  "host": {"cpu": os.cpu_count(), "ram_gb": round(psutil.virtual_memory().total / 2**30)}}
    RESULTS.write_text(json.dumps(r, indent=1)); SITE.mkdir(exist_ok=True); shutil.copy(RESULTS, SITE / "results.json")
    print(json.dumps(update, indent=1))

def run(names, dbs, continents):
    ensure_replset()
    for c in continents:
        base, out = rows(c), {}
        for name in names:
            for db in dbs:
                print(f"[{c}] {name} {db} ...", flush=True)
                res = EXPERIMENTS[name](db, c, base) if name in ("load", "query", "update") else EXPERIMENTS[name](db, c)
                for k, v in res.items():
                    out.setdefault(k, {})[db] = v
        save({c: out})

def selftest():
    global rows
    sample = [dict({f"y{y}": "" for y in range(1961, 2020)}, area="A", item="X", element="Production", unit="tonnes", y2005="12.7")]
    real, rows = rows, lambda c: sample
    try:
        e = explore("x"); assert e["areas"]["A"]["X"][2005 - 1961] == 13 and e["areas"]["A"]["X"][0] is None
    finally:
        rows = real
    assert with_ids([{"a": 1}], "z")[0]["_id"] == "z_0"
    assert parse_cgroup("usage_usec 1500000 user_usec 1 system_usec 2 104857600".split()) == (1.5, 100.0)
    assert parse_cgroup(["2000000000", "52428800"]) == (2.0, 50.0)
    assert pick([{"area": "A", "item": "X", "element": "Production", "y2005": "1"}, {"area": "A", "item": "X", "element": "Yield", "y2005": "1"},
                 {"area": "A", "item": "Y", "element": "Production", "y2005": ""}, {"area": "A", "item": "Z", "element": "Production", "y2005": "2"}]) == ("A", ["X", "Z"])
    print("selftest ok")

if __name__ == "__main__":
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("cmd", choices=["all", *EXPERIMENTS, "explore", "selftest"])
    p.add_argument("--db", choices=["mongo", "couch", "both"], default="both")
    p.add_argument("--continent", choices=[*CONTINENTS, "all"], default="all")
    p.add_argument("--runs", type=int, default=RUNS); p.add_argument("--warmup", type=int, default=WARMUP)
    a = p.parse_args()
    RUNS, WARMUP = a.runs, a.warmup
    continents = CONTINENTS if a.continent == "all" else [a.continent]
    if a.cmd == "selftest":
        selftest()
    elif a.cmd == "explore":
        for c in continents:
            (SITE / f"explore_{c}.json").write_text(json.dumps(explore(c), separators=(",", ":")))
            print(c, (SITE / f"explore_{c}.json").stat().st_size // 1024, "KB")
    else:
        run(list(EXPERIMENTS) if a.cmd == "all" else [a.cmd], ["mongo", "couch"] if a.db == "both" else [a.db], continents)
        if a.cmd == "all":
            for c in continents:
                (SITE / f"explore_{c}.json").write_text(json.dumps(explore(c), separators=(",", ":")))

Explore the data

Production in tonnes, 1961 to 2019, straight from the CSVs. Pick a continent, a country or region, and a crop.

Loading…