External Logging: All Persistent Types

The first external-logging test proved that a million-field hash could survive a native snapshot, a Kafka-journaled suffix, and SIGKILL. The next test made the persistence boundary broad instead of deep: strings, hashes, sets, sorted sets, lists, and sparse arrays all crossed the same save, crash, load, continued write, and exhaustive verification cycle.

Pub/Sub is not included because it has no persistent value to save. This test used the default efficient hash, segmented list, and classic array implementations.

Commands under test

Thunder's systemd unit started Goblin Core with this exact argument vector:

/usr/local/bin/goblin-core \
  --port 6379 \
  --numa eno1 \
  --load /mnt/local/goblin-core/state/goblin.snapshot \
  --kafka kafka://192.168.1.49:9092/goblin-core-replication

--load restores the native snapshot. --kafka first consumes the named single-partition topic to its high watermark and then journals new primary writes to the same topic. Goblin Core does not open its client listeners until startup replay is complete.

The test itself was selected entirely from its command line:

benchmarks/external_logging_recovery.sh \
  --data-type all \
  --snapshot-at 500000 \
  --count 1000000 \
  --pipeline 512

The same runner can isolate one type, for example:

benchmarks/external_logging_recovery.sh --data-type list
benchmarks/external_logging_recovery.sh --data-type zset --pipeline 256

Workload

Each ordinal was stored in the native shape of its type:

Type Write Verification
String SET external-logging-test:string:N N GET
Hash HSET external-logging-test:hash N N HLEN, HGET
Set SADD external-logging-test:set N SCARD, SISMEMBER
Sorted set ZADD external-logging-test:zset N N ZCARD, ZSCORE
List RPUSH external-logging-test:list N LLEN, LINDEX
Array ARSET external-logging-test:array N N ARLEN, ARGET

The runner cleared this fixed test namespace before starting; cleanup was outside the timed phases. Decimal ordinals traveled through the normal client protocol with Goblin Core's value encoding enabled.

The C++ client used RESP2 over TCP to 127.0.0.1:6379, pipelined 512 commands deep. Goblin Core used Kafka over TCP to Redpanda at 192.168.1.49:9092. Both services ran on the same Dell PowerEdge R820 described in the first test, and Redpanda remained limited to four cores and 8 GiB in production mode.

The runner wrote 500,000 entries of every type, for three million selected values at the boundary, then requested an accelerated native snapshot. It waited for the temporary snapshot to be fsynced and atomically renamed, sent SIGKILL to Goblin Core, and did not construct a restart command. Systemd restarted the unchanged unit.

Once the listener reopened, the client exhaustively verified all three million recovered values before it was allowed to continue. It then wrote ordinals 500000 through 999999 for every type and checked all six million final values.

Write throughput while logging

Every write was converted into a canonical RESP2 mutation and submitted to the idempotent, acks=all Redpanda producer. These are pipelined throughput rates, not single-command latency or synchronous-fsync rates.

Type First 500K ops/s Continued 500K ops/s
String 188,836 173,840
Hash 99,677 190,972
Set 219,961 214,586
Sorted set 162,370 163,722
List 221,859 198,396
Array 246,508 239,744
Sequential aggregate 173,345 193,746

The first three million writes took 17.31 seconds. The three million continued writes took 15.48 seconds. The hash phase changed shape substantially across the boundary; this persistence test does not isolate whether table growth, promotion, allocator state, or producer backlog caused that difference, so it should not be treated as a standalone HSET benchmark.

Crash and recovery

The snapshot was 102,229,100 bytes (97.5 MiB). This was not an empty-server size measurement: it also contained the million-field foo hash retained from the first recovery test and two small pre-existing keys. The startup report loaded 3.5 million collection members across 500,008 keys.

Systemd observed exit status 9/KILL, waited its configured one second, and started a replacement process. Goblin Core loaded the snapshot, resumed Kafka inclusively at broker offset 5998696 after logical offset 6000009, filtered the already represented boundary, and reopened its listener 5.72 seconds after the kill.

The systemd unit definition and the raw NUL-delimited process command line had identical hashes before and after:

86199041dabad28a6388075bd9be9b7fe61d0408f53e574a55ee341c38bd9ee3

Verification

All recovered halves matched before continued writes began:

Type Recovered values Missing Wrong Read ops/s
String 500,000 0 0 595,489
Hash 500,000 0 0 514,810
Set 500,000 0 0 766,991
Sorted set 500,000 0 0 1,009,091
List 500,000 0 0 888,564
Array 500,000 0 0 1,249,047

After the second half was written, cardinality was one million for every collection and every final value matched:

Type Final values Missing Wrong Read ops/s
String 1,000,000 0 0 823,982
Hash 1,000,000 0 0 687,456
Set 1,000,000 0 0 1,164,571
Sorted set 1,000,000 0 0 1,215,437
List 1,000,000 0 0 894,141
Array 1,000,000 0 0 1,246,103
Total 6,000,000 0 0 956,848 aggregate

At completion, Goblin Core reported 188.3 MiB used memory and 283.7 MiB RSS. Its logical replication offset was 9000009, its last acknowledged Kafka record was 9000008, and Redpanda's high watermark was 9000009.

Finding

Every persistent type survived the same hard process death, was available with the exact saved contents after startup replay, accepted another 500,000 logged writes, and retained a fully correct million-value result. This is the behavior the external-logging design promises: Goblin Core owns compact hot state and native snapshots; Redpanda owns the ordered durable history between them.

The next test adds a live follower and kills both Goblin processes, one after the other: Staggered Primary and Replica Crashes.