Callbacks and exceptions
A managed exception must never propagate into native code. The runtime treats that as unrecoverable and terminates the process, so every callback this library installs catches exceptions at the boundary and reports them through RocksDbCallbacks.UnhandledException.
Subscribe to that event, because an exception in a callback is otherwise invisible:
RocksDbCallbacks.UnhandledException += (sender, e) =>
logger.LogError(e.Exception, "RocksDb callback {Callback} on {Source} threw", e.CallbackName, sender);
Handlers run on whichever thread raised the exception, which is a RocksDb background thread for flush, compaction and backup events, so they must be thread-safe. A handler that itself throws is ignored, so it cannot mask the failure it was reporting.
Which instance threw
The sender is the wrapper whose callback threw. It matters as soon as an application installs more than one, because the callback name does not identify them: two compaction filters both report under Filter.
RocksDbCallbacks.UnhandledException += (sender, e) =>
{
if (ReferenceEquals(sender, archiveFilter))
{
logger.LogError(e.Exception, "The archive compaction filter threw");
}
};
For callbacks installed as a subclass the sender is that instance. For the two installed as a plain delegate, ReadOptions.SetTableFilter and the CreateBackupOptions callbacks, it is the delegate itself, because that is what the callback holds. Compare against whichever you registered.
It is null only when the source could not be identified, which happens when resolving it is itself what failed.
What happens after the exception
Each callback degrades to the outcome that cannot lose or alter data:
| Callback | Behaviour when it throws |
|---|---|
CompactionFilter.Filter |
Entry kept unchanged |
CompactionFilterFactory.CreateFilter |
No filter for that compaction job |
MergeOperator.FullMerge |
Merge fails, so the read reports a corruption error |
MergeOperator.PartialMerge |
Operands kept and merged later by FullMerge |
Logger.Log |
Log line dropped |
EventListener events |
Notification skipped |
ReadOptions table filter |
File included in the read |
WalFilter.LogRecordFound |
Record applied as written |
CreateBackupOptions exclude-files |
File included in the backup |
Comparator.Compare |
Process terminates |
Two of these are worth understanding rather than memorising.
The table filter includes the file. Excluding it is the one outcome that would silently hide data from a read, so a throwing filter is treated as permissive.
Comparator.Compare fails fast. It has no failure channel: it must return an ordering, and any value invented misrepresents key order for data RocksDb then writes and later reads back. Terminating with a message naming the callback is worse than working code and far better than silent corruption. Handle exceptions inside your comparator.
Threading
- Most
EventListenercallbacks,CompactionFilter, andMergeOperator.PartialMergerun on RocksDb background threads, concurrently when several flushes or compactions are in flight. Make them thread-safe, or useCompactionFilterFactoryto get one filter instance per job. - Two run on the thread that caused the event instead, measured rather than assumed:
MergeOperator.FullMergeruns on the reader's thread during aGet, andEventListener.OnMemTableSealedruns on the writer's. Being on your own thread is not permission to be slow — a merge operator that blocks blocks the read that called it. - The
ReadOptionstable filter runs on the reader's own thread, once per candidate SST file per read, so keep it cheap. - The backup progress and exclude-files callbacks run on copy threads, concurrently when
BackupEngineOptions.MaxBackgroundOperationsis above one. WalFilterruns duringRocksDb.Openon the calling thread and never concurrently.
Options are mostly read at open time
Nearly every DbOptions value is read once when the database opens and ignored afterwards. Setting one on a live DbOptions does nothing.
RocksDb.SetDbOptions is the runtime path for database-scoped options, and RocksDb.SetOptions for column-family ones. The two scopes are distinct: max_background_jobs is accepted by the first and rejected by the second, and write_buffer_size the other way round.