Verify: migrate from Aerospike to KiviDB
verify.py compares Aerospike with what the loader wrote: record counts per set, then a random sample of records field by field (with the loader's own…
verify.py compares Aerospike with what the loader wrote: record counts per
set, then a random sample of records field by field (with the loader's own
conversion), then the TTLs of the sampled records. Save it next to
as2kivi.py, which it imports.
#!/usr/bin/env python3
"""Compare an Aerospike namespace with what as2kivi.py wrote to KiviDB.
1. Record counts per set (Aerospike's set statistics against a SCAN of the
matching key names in KiviDB).
2. A random sample of records, bin by bin, using the same conversion as
as2kivi.py (keep the two files side by side).
3. TTLs of the sampled records, within a tolerance.
"""
import argparse
import json
import math
import random
import sys
import aerospike
import redis
from as2kivi import NEVER_EXPIRES, Stats, convert_hash, convert_json, key_part
def aerospike_set_counts(asc, ns):
"""Master objects per set, summed over the cluster."""
counts = {}
for _node, (err, text) in asc.info_all(f"sets/{ns}").items():
if err:
continue
for entry in text.strip().rstrip(";").split(";"):
fields = dict(kv.split("=", 1) for kv in entry.split(":") if "=" in kv)
if "set" in fields:
counts[fields["set"]] = counts.get(fields["set"], 0) + int(fields["objects"])
return counts
def as_float(b):
try:
return float(b)
except ValueError:
return None
def as_json(b):
try:
v = json.loads(b)
return v if isinstance(v, (list, dict)) else None
except ValueError:
return None
def same(want, got, rounded):
"""Equal, except that a float may differ in its last digit (counted)."""
if isinstance(want, float) and isinstance(got, (int, float)) and want != got:
if math.isclose(want, got, rel_tol=1e-15):
rounded[0] += 1
return True
return False
if isinstance(want, dict) and isinstance(got, dict):
return want.keys() == got.keys() and all(same(want[k], got[k], rounded) for k in want)
if isinstance(want, list) and isinstance(got, list):
return len(want) == len(got) and all(same(w, g, rounded) for w, g in zip(want, got))
return want == got
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--aerospike", required=True)
ap.add_argument("--as-user"); ap.add_argument("--as-password")
ap.add_argument("--namespace", required=True)
ap.add_argument("--replication-factor", type=int, default=1,
help="the namespace's replication-factor: set counts include replicas")
ap.add_argument("--set", action="append", dest="sets", help="only these sets, as given to as2kivi.py")
ap.add_argument("--kividb", required=True)
ap.add_argument("--ca")
ap.add_argument("--cluster", action="store_true")
ap.add_argument("--mode", choices=["hash", "json"], default="hash")
ap.add_argument("--prefix", default="")
ap.add_argument("--gen-field")
ap.add_argument("--no-key", choices=["digest", "skip"], default="digest")
ap.add_argument("--sample", type=int, default=1000)
ap.add_argument("--ttl-slack", type=int, default=300,
help="seconds of TTL difference allowed (the copy took time)")
a = ap.parse_args()
host, port = a.aerospike.rsplit(":", 1)
cfg = {"hosts": [(host, int(port))]}
if a.as_user:
cfg["user"], cfg["password"] = a.as_user, a.as_password
asc = aerospike.client(cfg).connect()
kw = {"ssl_ca_certs": a.ca} if a.ca else {}
kv = (redis.RedisCluster if a.cluster else redis.Redis).from_url(a.kividb, **kw)
ns, failures = a.namespace, 0
# 1. counts
for set_name, n in sorted(aerospike_set_counts(asc, ns).items()):
if a.sets and set_name not in a.sets:
continue
n //= a.replication_factor
got = sum(1 for _ in kv.scan_iter(match=f"{a.prefix}{ns}:{set_name}:*", count=1000))
status = "ok" if got == n else "MISMATCH"
failures += got != n
print(f"count {set_name}: aerospike {n}, kividb {got} {status}")
# 2 and 3. a sample from random partitions
convert = convert_hash if a.mode == "hash" else convert_json
stats, checked, rounded = Stats(), 0, [0]
partitions = random.sample(range(4096), 4096)
for pid in partitions:
if checked >= a.sample:
break
recs = asc.query(ns, None).results({"partition_filter": {"begin": pid, "count": 1}})
for key, meta, bins in recs:
if checked >= a.sample:
break
set_name, user_key, digest = key[1] or "", key[2], key[3]
if a.sets and set_name not in a.sets:
continue
if user_key is None and a.no_key == "skip":
continue
kpart = key_part(user_key) if user_key is not None else b"#" + bytes(digest).hex().encode()
name = f"{a.prefix}{ns}:{set_name}:".encode() + kpart
want, _ = convert(bins, stats)
if a.gen_field:
want[a.gen_field] = meta["gen"] if a.mode == "json" else str(meta["gen"])
if a.mode == "hash":
got = kv.hgetall(name)
want = {k.encode(): (v if isinstance(v, bytes) else v.encode()) for k, v in want.items()}
else:
raw = kv.execute_command("JSON.GET", name, "$")
got = json.loads(raw)[0] if raw else None
checked += 1
if a.mode == "hash" and got.keys() == want.keys():
# a float written by another loader may be formatted differently (1.0E7)
for f in got:
if got[f] == want[f]:
continue
if as_float(got[f]) is not None and as_float(got[f]) == as_float(want[f]):
got[f] = want[f]
elif as_json(got[f]) is not None and as_json(got[f]) == as_json(want[f]):
got[f] = want[f] # the same list or map, keys in another order
if not same(want, got, rounded):
failures += 1
print(f"DIFF {name!r}: aerospike {want!r} kividb {got!r}")
continue
ttl = kv.ttl(name)
if meta["ttl"] == NEVER_EXPIRES:
ok = ttl == -1
else:
ok = ttl > 0 and abs(ttl - meta["ttl"]) <= a.ttl_slack
if not ok:
failures += 1
print(f"TTL {name!r}: aerospike {meta['ttl']} kividb {ttl}")
if rounded[0]:
print(f"{rounded[0]} JSON numbers differ from Aerospike in the last digit only")
print(f"sampled {checked} records, {failures} problems")
sys.exit(1 if failures else 0)
if __name__ == "__main__":
main()
Run it with the same --namespace, --mode, --prefix, --set,
--gen-field and --no-key you gave the loader, and the namespace's
replication factor:
python verify.py --aerospike 10.0.0.5:3000 --namespace app --replication-factor 2 \
--kividb redis://:"$KIVIDB_PASSWORD"@kividb.internal:6380 --mode json --sample 5000
A clean run:
count events: aerospike 200, kividb 200 ok
count orders: aerospike 5000, kividb 5000 ok
count users: aerospike 5000, kividb 5000 ok
sampled 5000 records, 0 problems
It exits with status 1 when anything differs, and prints each difference:
count orders: aerospike 5000, kividb 4999 MISMATCH
DIFF b'app:orders:5': aerospike {b'total': b'7.5', b'items': b'5', b'user': b'u5'} kividb {}
TTL b'app:users:u3': aerospike 3403 kividb -1
While the application is still writing, counts and values can differ by records written since the copy; run it after the catch-up and prune passes, or with writes stopped. The count pass scans every key in the namespace's prefix, so run it against a replica or off-peak on very large datasets.
On a self-hosted KiviDB, INFO keyspace gives the total keys (and how many
have an expiry) per database, a quick first check against the sum of
Aerospike's set counts.