public async Task LogCompaction_IncreasesThresholdAfterFiveConsecutiveCompactions()
        {
            // Arrange
            var resultStringBuilder = new StringBuilder();
            ILogger <MixedStorageKVStore <int, DummyClass> > dummyLogger = CreateLogger <int, DummyClass>(resultStringBuilder, LogLevel.Trace);
            var logSettings = new LogSettings
            {
                LogDevice = Devices.CreateLogDevice(Path.Combine(_fixture.TempDirectory, $"{nameof(LogCompaction_IncreasesThresholdAfterFiveConsecutiveCompactions)}.log"),
                                                    deleteOnClose: true),
                PageSizeBits   = 12,
                MemorySizeBits = 13
            };
            DummyClass dummyClassInstance = CreatePopulatedDummyClassInstance();
            var        dummyOptions       = new MixedStorageKVStoreOptions()
            {
                TimeBetweenLogCompactionsMS        = 1,
                InitialLogCompactionThresholdBytes = 20_000 // So we compact 5 times in a row
            };
            // Create and populate faster KV store before passing it to MixedStorageKVStore, at which point the compaction loop starts.
            // For quicker tests, use thread local sessions.
            FasterKV <SpanByte, SpanByte>?dummyFasterKVStore = null;
            ThreadLocal <ClientSession <SpanByte, SpanByte, SpanByte, SpanByteAndMemory, Empty, SpanByteFunctions <Empty> > >?dummyThreadLocalSession = null;

            try
            {
                dummyFasterKVStore = new FasterKV <SpanByte, SpanByte>(1L << 20, logSettings);
                FasterKV <SpanByte, SpanByte> .ClientSessionBuilder <SpanByte, SpanByteAndMemory, Empty> dummyClientSessionBuilder = dummyFasterKVStore.For(new SpanByteFunctions <Empty>());
                dummyThreadLocalSession = new(() => dummyClientSessionBuilder.NewSession <SpanByteFunctions <Empty> >(), true);
                MessagePackSerializerOptions dummyMessagePackSerializerOptions = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray);

                // Record size estimate:
                // - dummyClassInstance serialized and compressed = ~73 bytes
                // - int key serialized and compressed = ~3 bytes
                // - key length metadata = 4 bytes
                // - value length metadata = 4 bytes
                // - record header = 8 bytes
                // Total = ~92 bytes
                //
                // n * 92 - 8192 > InitialLogCompactionThresholdBytes

                // Insert
                byte[] dummyValueBytes = MessagePackSerializer.Serialize(dummyClassInstance, dummyMessagePackSerializerOptions);
                Parallel.For(0, 500, key =>
                {
                    byte[] dummyKeyBytes = MessagePackSerializer.Serialize(key, dummyMessagePackSerializerOptions);
                    unsafe
                    {
                        // Upsert
                        fixed(byte *keyPointer   = dummyKeyBytes)
                        fixed(byte *valuePointer = dummyValueBytes)
                        {
                            var keySpanByte = SpanByte.FromPointer(keyPointer, dummyKeyBytes.Length);
                            var objSpanByte = SpanByte.FromPointer(valuePointer, dummyValueBytes.Length);
                            dummyThreadLocalSession.Value.Upsert(ref keySpanByte, ref objSpanByte);
                        }
                    }
                });
                // Update so compaction does something. Can't update in insert loop or we'll get a bunch of in-place updates.
                dummyClassInstance.DummyInt++;
                dummyValueBytes = MessagePackSerializer.Serialize(dummyClassInstance, dummyMessagePackSerializerOptions);
                Parallel.For(0, 500, key =>
                {
                    byte[] dummyKeyBytes = MessagePackSerializer.Serialize(key, dummyMessagePackSerializerOptions);
                    unsafe
                    {
                        // Upsert
                        fixed(byte *keyPointer   = dummyKeyBytes)
                        fixed(byte *valuePointer = dummyValueBytes)
                        {
                            var keySpanByte = SpanByte.FromPointer(keyPointer, dummyKeyBytes.Length);
                            var objSpanByte = SpanByte.FromPointer(valuePointer, dummyValueBytes.Length);
                            dummyThreadLocalSession.Value.Upsert(ref keySpanByte, ref objSpanByte);
                        }
                    }
                });
            }
            finally
            {
                if (dummyThreadLocalSession != null)
                {
                    foreach (ClientSession <SpanByte, SpanByte, SpanByte, SpanByteAndMemory, Empty, SpanByteFunctions <Empty> > session in dummyThreadLocalSession.Values)
                    {
                        session.Dispose(); // Faster synchronously completes all pending operations, so we should not get exceptions if we're in the middle of log compaction
                    }
                }
            }

            // Runs 5 consecutive compactions, increases threshold, runs 5 more consecutive compactions (all redundant), increases threshold above
            // safe-readonly region size, skips compactions thereafter.
            string expectedResultStart       = @$ "{LogLevel.Trace}: {string.Format(Strings.LogTrace_LogCompacted, 90048, 72000, 1)}
{LogLevel.Trace}: {string.Format(Strings.LogTrace_LogCompacted, 72000, 57600, 2)}
{LogLevel.Trace}: {string.Format(Strings.LogTrace_LogCompacted, 57600, 46080, 3)}
        public async Task LogCompaction_OccursIfSafeReadOnlyRegionIsLargerThanThreshold()
        {
            // Arrange
            var resultStringBuilder = new StringBuilder();
            ILogger <MixedStorageKVStore <int, DummyClass> > dummyLogger = CreateLogger <int, DummyClass>(resultStringBuilder, LogLevel.Trace);
            var logSettings = new LogSettings
            {
                LogDevice = Devices.CreateLogDevice(Path.Combine(_fixture.TempDirectory, $"{nameof(LogCompaction_OccursIfSafeReadOnlyRegionIsLargerThanThreshold)}.log"),
                                                    deleteOnClose: true),
                PageSizeBits   = 12,
                MemorySizeBits = 13
            };
            DummyClass dummyClassInstance = CreatePopulatedDummyClassInstance();
            var        dummyOptions       = new MixedStorageKVStoreOptions()
            {
                TimeBetweenLogCompactionsMS        = 1,
                InitialLogCompactionThresholdBytes = 80_000
            };
            // Create and populate faster KV store before passing it to MixedStorageKVStore, at which point the compaction loop starts.
            // For quicker tests, use thread local sessions.
            FasterKV <SpanByte, SpanByte>?dummyFasterKVStore = null;
            ThreadLocal <ClientSession <SpanByte, SpanByte, SpanByte, SpanByteAndMemory, Empty, SpanByteFunctions <Empty> > >?dummyThreadLocalSession = null;

            try
            {
                dummyFasterKVStore = new FasterKV <SpanByte, SpanByte>(1L << 20, logSettings);
                FasterKV <SpanByte, SpanByte> .ClientSessionBuilder <SpanByte, SpanByteAndMemory, Empty> dummyClientSessionBuilder = dummyFasterKVStore.For(new SpanByteFunctions <Empty>());
                dummyThreadLocalSession = new(() => dummyClientSessionBuilder.NewSession <SpanByteFunctions <Empty> >(), true);
                MessagePackSerializerOptions dummyMessagePackSerializerOptions = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4BlockArray);

                // Record size estimate:
                // - dummyClassInstance serialized and compressed = ~73 bytes
                // - int key serialized and compressed = ~3 bytes
                // - key length metadata = 4 bytes
                // - value length metadata = 4 bytes
                // - record header = 8 bytes
                // Total = ~92 bytes
                //
                // n * 92 - 8192 > InitialLogCompactionThresholdBytes

                // Insert
                byte[] dummyValueBytes = MessagePackSerializer.Serialize(dummyClassInstance, dummyMessagePackSerializerOptions);
                Parallel.For(0, 500, key =>
                {
                    byte[] dummyKeyBytes = MessagePackSerializer.Serialize(key, dummyMessagePackSerializerOptions);
                    unsafe
                    {
                        // Upsert
                        fixed(byte *keyPointer   = dummyKeyBytes)
                        fixed(byte *valuePointer = dummyValueBytes)
                        {
                            var keySpanByte = SpanByte.FromPointer(keyPointer, dummyKeyBytes.Length);
                            var objSpanByte = SpanByte.FromPointer(valuePointer, dummyValueBytes.Length);
                            dummyThreadLocalSession.Value.Upsert(ref keySpanByte, ref objSpanByte);
                        }
                    }
                });
                // Update so compaction does something. Can't update in insert loop or we'll get a bunch of in-place updates.
                dummyClassInstance.DummyInt++;
                dummyValueBytes = MessagePackSerializer.Serialize(dummyClassInstance, dummyMessagePackSerializerOptions);
                Parallel.For(0, 500, key =>
                {
                    byte[] dummyKeyBytes = MessagePackSerializer.Serialize(key, dummyMessagePackSerializerOptions);
                    unsafe
                    {
                        // Upsert
                        fixed(byte *keyPointer   = dummyKeyBytes)
                        fixed(byte *valuePointer = dummyValueBytes)
                        {
                            var keySpanByte = SpanByte.FromPointer(keyPointer, dummyKeyBytes.Length);
                            var objSpanByte = SpanByte.FromPointer(valuePointer, dummyValueBytes.Length);
                            dummyThreadLocalSession.Value.Upsert(ref keySpanByte, ref objSpanByte);
                        }
                    }
                });
            }
            finally
            {
                if (dummyThreadLocalSession != null)
                {
                    foreach (ClientSession <SpanByte, SpanByte, SpanByte, SpanByteAndMemory, Empty, SpanByteFunctions <Empty> > session in dummyThreadLocalSession.Values)
                    {
                        session.Dispose(); // Faster synchronously completes all pending operations, so we should not get exceptions if we're in the middle of log compaction
                    }
                }
            }

            // We compact 20% of the safe-readonly region of the log. Since we inserted then updated, compaction here means removal.
            // 90048 * 0.8 = 72038, ~72000 (missing 38 bytes likely has to do with the fact that we can't remove only part of a record).
            string expectedResultStart       = $"{LogLevel.Trace}: {string.Format(Strings.LogTrace_LogCompacted, 90048, 72000, 1)}";
            int    expectedResultStartLength = expectedResultStart.Length;

            // Act
            using (var testSubject = new MixedStorageKVStore <int, DummyClass>(dummyOptions, dummyLogger, dummyFasterKVStore)) // Start log compaction
            {
                while (resultStringBuilder.Length <= expectedResultStartLength)
                {
                    await Task.Delay(10).ConfigureAwait(false);
                }
            }

            // Assert
            Assert.StartsWith(expectedResultStart, resultStringBuilder.ToString());
            // If compaction runs more than once, should be skipped after first compaction (< threshold behaviour verified in previous test)
        }