Fix NRT errors and enable TreatWarningsAsErrors#159
Conversation
- Replace ineffective WarningsAsErrors=true with TreatWarningsAsErrors=true - Add missing where T : notnull constraints on list cache methods - Fix nullable reference dereferences in queue tests and samples - Add [return: MaybeNull] to ToValueOfType<T>() - Guard null IQueueEntry.Value access in tests with Assert.NotNull - Use null-conditional/coalescing for nullable message envelope properties - Fix LogState.Property calls to handle nullable arguments
There was a problem hiding this comment.
Pull request overview
This PR enables nullable reference types and treats warnings as errors across the solution, then updates Foundatio.Redis library code, tests, benchmarks, and samples to satisfy the stricter compiler settings while preserving runtime behavior.
Changes:
- Enabled
<Nullable>enable</Nullable>and updated build settings to properly treat warnings as errors. - Updated Foundatio.Redis library code for null-safety (option defaults, deserialization guards, event handlers, generic constraints).
- Updated tests/benchmarks/samples to align with nullable annotations and updated Foundatio package versions.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| build/common.props | Enables nullable and enforces warnings-as-errors behavior. |
| src/Foundatio.Redis/Utility/EmbeddedResourceLoader.cs | Adds explicit exception when an embedded resource is missing. |
| src/Foundatio.Redis/Extensions/RedisExtensions.cs | Annotates potential null returns and tightens casts for NRT compliance. |
| src/Foundatio.Redis/Cache/RedisCacheClient.cs | Adds where T : notnull for list APIs and adjusts null-handling during (de)serialization. |
| src/Foundatio.Redis/Cache/RedisCacheClientOptions.cs | Marks required options as non-null with null! initialization. |
| src/Foundatio.Redis/Cache/RedisHybridCacheClient.cs | Updates optional parameters to nullable to satisfy NRT. |
| src/Foundatio.Redis/Queues/RedisQueue.cs | Applies null-safety to queue entry flow and payload envelope types. |
| src/Foundatio.Redis/Queues/RedisQueueOptions.cs | Marks required options as non-null with null! initialization. |
| src/Foundatio.Redis/Storage/RedisFileStorage.cs | Null-safety improvements in stream/info retrieval and file listing logic/logging. |
| src/Foundatio.Redis/Storage/RedisFileStorageOptions.cs | Marks required options as non-null with null! initialization. |
| src/Foundatio.Redis/Messaging/RedisMessageBus.cs | Adds null-safe envelope handling and delayed-message type resolution checks. |
| src/Foundatio.Redis/Messaging/RedisMessageBusOptions.cs | Marks required Subscriber as non-null with null! initialization and cleans file header. |
| src/Foundatio.Redis/Foundatio.Redis.csproj | Bumps Foundatio dependency version. |
| tests/Foundatio.Redis.Tests/SharedConnection.cs | Updates muxer fields and API to be nullable-aware. |
| tests/Foundatio.Redis.Tests/Utility/RedisPatternTests.cs | Adjusts nullability in tests for RedisPattern.Escape. |
| tests/Foundatio.Redis.Tests/Storage/RedisFileStorageTests.cs | Uses null-forgiving on muxer retrieval in test setup/creation. |
| tests/Foundatio.Redis.Tests/Queues/RedisQueueTests.cs | Updates queue factory signature and adds null guards/assertions in tests. |
| tests/Foundatio.Redis.Tests/Messaging/RedisMessageBusTests.cs | Updates nullable annotations and muxer usage. |
| tests/Foundatio.Redis.Tests/Locks/RedisLockTests.cs | Uses null-forgiving muxer retrieval for initialization. |
| tests/Foundatio.Redis.Tests/Jobs/RedisJobQueueTests.cs | Uses null-forgiving muxer retrieval for initialization. |
| tests/Foundatio.Redis.Tests/Caching/RedisCacheClientTests.cs | Uses null-forgiving muxer retrieval; adjusts nullable casts in list removal. |
| tests/Foundatio.Redis.Tests/Caching/RedisHybridCacheClientTests.cs | Uses null-forgiving muxer retrieval for distributed cache client. |
| tests/Foundatio.Redis.Tests/Caching/ScopedRedisCacheClientTests.cs | Uses null-forgiving muxer retrieval; updates nullable test inputs. |
| tests/Foundatio.Redis.Tests/Caching/ScopedRedisHybridCacheClientTests.cs | Uses null-forgiving muxer retrieval; updates nullable test inputs. |
| tests/Foundatio.Redis.Tests/Foundatio.Redis.Tests.csproj | Bumps Foundatio.TestHarness dependency version. |
| tests/Foundatio.Benchmarks/Queues/QueueBenchmarks.cs | Adds null-conditional completion in dequeue benchmark loop. |
| tests/Foundatio.Benchmarks/Queues/JobQueueBenchmarks.cs | Makes optional logger factory parameters nullable. |
| samples/Foundatio.SampleJobClient/Program.cs | Initializes static fields and message models for NRT compliance. |
| samples/Foundatio.SampleJobClient/Foundatio.SampleJobClient.csproj | Bumps Foundatio.Xunit.v3 dependency version. |
| samples/Foundatio.SampleJob/SampleServiceProvider.cs | Adds null-forgiving on logger factory usage in DI setup. |
| samples/Foundatio.SampleJob/Program.cs | Initializes static logger and message model for NRT compliance. |
| samples/Foundatio.SampleJob/PingQueueJob.cs | Makes lock acquisition nullable-aware and updates PingRequest model for NRT compliance. |
Comments suppressed due to low confidence (1)
src/Foundatio.Redis/Messaging/RedisMessageBus.cs:99
envelope.Propertiesandenvelope.Dataare assumed non-null after deserialization, but a deserializer can legitimately producenullfor reference-type properties (e.g., older payloads or explicit nulls). Guard againstenvelope.Data == nulland iterate properties only whenenvelope.Properties != null(or normalize them to empty) to avoid runtime NREs when receiving malformed/legacy messages.
var envelope = _serializer.Deserialize<RedisMessageEnvelope>(((byte[]?)channelMessage.Message)!);
if (envelope is null)
{
_logger.LogWarning("OnMessage({Channel}) Deserialized envelope was null", channelMessage.Channel);
return;
}
message = new Message(envelope.Data, DeserializeMessageBody)
{
Type = envelope.Type ?? string.Empty,
ClrType = envelope.Type is not null ? GetMappedMessageType(envelope.Type) : null,
CorrelationId = envelope.CorrelationId,
UniqueId = envelope.UniqueId
};
foreach (var property in envelope.Properties)
message.Properties.Add(property.Key, property.Value);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…rectness - SharedConnection.GetMuxer: throw InvalidOperationException instead of returning null when RedisConnectionString is missing, giving a clear diagnostic message at the point of failure. - Remove all null-forgiving operators (!) on GetMuxer calls across every test file since the method now guarantees a non-null return. - QueueBenchmarks.DequeueQueue: replace silent entry?.CompleteAsync() with an explicit null throw so benchmark results are never skewed by silently dropped dequeues. - RedisMessageBus.OnMessage: guard envelope.Properties (null check before iterating) and envelope.Data (coalesce to empty array) after deserialization. Mark both properties as nullable on RedisMessageEnvelope.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Revert logging hacks: restore original criteria.Prefix/Pattern patterns - Revert NormalizePath change: use NormalizePath() in GetFileListAsync - Fix string. -> String. globally across the repo - Revert cast hack in RedisCacheClientTests: use items[10].ToString() - Restore removed assertions in RedisQueueTests - SharedConnection.GetMuxer: return null instead of throwing, return ConnectionMultiplexer?
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| { | ||
| T value; | ||
| if (redisValue.IsNull) | ||
| { | ||
| if (!typeof(T).IsValueType || Nullable.GetUnderlyingType(typeof(T)) is not null) | ||
| return default!; | ||
|
|
||
| throw new InvalidOperationException($"Cannot convert null Redis value to non-nullable type {typeof(T).Name}"); | ||
| } | ||
|
|
||
| var type = typeof(T); | ||
|
|
||
| if (type == TypeHelper.BoolType || type == TypeHelper.StringType || type.IsNumeric()) | ||
| value = (T)Convert.ChangeType(redisValue, type); | ||
| else if (type == TypeHelper.NullableBoolType || type.IsNullableNumeric()) | ||
| value = redisValue.IsNull ? default : (T)Convert.ChangeType(redisValue, Nullable.GetUnderlyingType(type)); | ||
| else | ||
| return serializer.Deserialize<T>((byte[])redisValue); | ||
| return (T)Convert.ChangeType(redisValue, type)!; | ||
|
|
||
| if (type == TypeHelper.NullableBoolType || type.IsNullableNumeric()) | ||
| { | ||
| var underlyingType = Nullable.GetUnderlyingType(type) | ||
| ?? throw new InvalidOperationException($"Expected nullable type but {type.Name} has no underlying type"); | ||
| return (T)Convert.ChangeType(redisValue, underlyingType)!; | ||
| } | ||
|
|
||
| return value; | ||
| byte[] bytes = (byte[]?)redisValue | ||
| ?? throw new InvalidOperationException($"Expected non-null byte[] from RedisValue for type {typeof(T).Name}"); | ||
| return serializer.Deserialize<T>(bytes); | ||
| } |
There was a problem hiding this comment.
I'm really not sure any of these changes are correct for this method implementation, this has been working for a decade like this
| timeout = timeout.TotalMilliseconds | ||
| }).AnyContext(); | ||
| return result.ToString(); | ||
| return (RedisValue)result; |
| if (searchPattern is not null) | ||
| searchPattern = NormalizePath(searchPattern); |
There was a problem hiding this comment.
why did this line need to change?
| .Select(entry => _serializer.Deserialize<FileSpec>((byte[])entry.Value)) | ||
| .Where(fileSpec => patternRegex == null || patternRegex.IsMatch(fileSpec.Path)) | ||
| .Where(entry => entry.Value.HasValue) | ||
| .Select(entry => _serializer.Deserialize<FileSpec>((byte[]?)entry.Value ?? [])) |
There was a problem hiding this comment.
why did this need ?? []
| .Select(entry => _serializer.Deserialize<FileSpec>((byte[])entry.Value)) | ||
| .Where(fileSpec => criteria.Pattern == null || criteria.Pattern.IsMatch(fileSpec.Path)) | ||
| .Where(entry => entry.Value.HasValue) | ||
| .Select(entry => _serializer.Deserialize<FileSpec>((byte[]?)entry.Value ?? [])) |
There was a problem hiding this comment.
why did this need ?? []
| return new RedisCacheClient(o => o.ConnectionMultiplexer(SharedConnection.GetMuxer(Log, Protocol)).LoggerFactory(Log).ShouldThrowOnSerializationError(shouldThrowOnSerializationError)); | ||
| var muxer = SharedConnection.GetMuxer(Log, Protocol); | ||
| if (muxer is null) | ||
| return null!; |
There was a problem hiding this comment.
if muxer is null we should throw no? or should this method return type be nullable? Also always whitespace after return, take a deeper look at this.
| return; | ||
| var db = muxer.GetDatabase(); |
There was a problem hiding this comment.
Log and return and always new line after return or }
| var muxer = SharedConnection.GetMuxer(Log, Protocol); | ||
| if (muxer is null) | ||
| return ValueTask.CompletedTask; | ||
| return new ValueTask(muxer.FlushAllAsync()); |
| return new ScopedCacheClient(new RedisCacheClient(o => o.ConnectionMultiplexer(SharedConnection.GetMuxer(Log, Protocol)).LoggerFactory(Log).ShouldThrowOnSerializationError(shouldThrowOnSerializationError)), "scoped"); | ||
| var muxer = SharedConnection.GetMuxer(Log, Protocol); | ||
| if (muxer is null) | ||
| return null!; |
| { | ||
| var muxer = SharedConnection.GetMuxer(Log, Protocol); | ||
| if (muxer is null) | ||
| return null!; |
There was a problem hiding this comment.
same feedback as before should this method return ICacheClient? also always newline after return
Addressing all remaining feedbackRedisCacheClient.cs .ToString() (lines 323, 905): RedisExtensions.cs ToValueOfType (line 36): RedisExtensions.cs ! on line 31: RedisQueue.cs options throw (line 46): RedisQueue.cs workId/waitId conversions: PingQueueJob.cs (lines 31, 43): RedisQueueTests.cs Assert.NotNull(entry.Value) (line 792): RedisPatternTests.cs (line 28): RedisFileStorage.cs line 252: RedisCacheClientTests.cs GetCacheClient return type: SharedConnection.cs GetMuxer: |
| return ValueTask.CompletedTask; | ||
| return new ValueTask(muxer.FlushAllAsync()); |
| return null!; | ||
| return new RedisQueue<SampleQueueWorkItem>(o => o |
There was a problem hiding this comment.
new line after eturn and also should this be returning IQueue? nullable.
| return ValueTask.CompletedTask; | ||
| return new ValueTask(muxer.FlushAllAsync()); |
| @@ -26,8 +26,8 @@ protected RedisLockTests(ITestOutputHelper output, RedisProtocol? protocol) : ba | |||
| { | |||
| _protocol = protocol; | |||
| var muxer = SharedConnection.GetMuxer(Log, _protocol); | |||
There was a problem hiding this comment.
should this throw if null? also newline after validation
| return null!; | ||
| return new RedisMessageBus(o => |
There was a problem hiding this comment.
should this be returning IMessageBus? and also newline after return
| return; | ||
| var messageBus1 = new RedisMessageBus(new RedisMessageBusOptions { Subscriber = muxer.GetSubscriber(), Topic = _topic, LoggerFactory = Log }); |
| return ValueTask.CompletedTask; | ||
| return new ValueTask(muxer.FlushAllAsync()); |
| } | ||
|
|
||
| protected override IQueue<SimpleWorkItem> GetQueue(int retries = 1, TimeSpan? workItemTimeout = null, TimeSpan? retryDelay = null, int[] retryMultipliers = null, int deadLetterMaxItems = 100, bool runQueueMaintenance = true, TimeProvider timeProvider = null, ISerializer serializer = null) | ||
| protected override IQueue<SimpleWorkItem> GetQueue(int retries = 1, TimeSpan? workItemTimeout = null, TimeSpan? retryDelay = null, int[]? retryMultipliers = null, int deadLetterMaxItems = 100, bool runQueueMaintenance = true, TimeProvider? timeProvider = null, ISerializer? serializer = null) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var muxer = SharedConnection.GetMuxer(Log, _protocol); | ||
| if (muxer is null) | ||
| { | ||
| _cache = null!; | ||
| _messageBus = null!; | ||
| return; | ||
| } |
There was a problem hiding this comment.
When SharedConnection.GetMuxer(...) returns null (missing RedisConnectionString), this constructor sets _cache/_messageBus to null! and continues. Subsequent methods (GetLockProvider, GetThrottlingLockProvider, and Dispose) unconditionally dereference these fields, leading to NullReferenceException rather than a clear skip/fail-fast. Consider throwing an xUnit SkipException (or similar) in this branch, or otherwise ensuring the class won’t execute tests/Dispose with null dependencies.
| @@ -373,7 +375,7 @@ protected override async Task<IQueueEntry<T>> DequeueImplAsync(CancellationToken | |||
| catch (Exception ex) | |||
| { | |||
| _logger.LogWarning(ex, "Error deserializing queue entry payload: {WorkId}, abandoning for retry", value); | |||
There was a problem hiding this comment.
The warning log message uses the {WorkId} placeholder but passes value (a RedisValue) instead of the workId string that was just computed. This makes logs harder to correlate and can produce unexpected output for non-string RedisValue representations; pass workId to match the placeholder and subsequent poison-entry handling.
| _logger.LogWarning(ex, "Error deserializing queue entry payload: {WorkId}, abandoning for retry", value); | |
| _logger.LogWarning(ex, "Error deserializing queue entry payload: {WorkId}, abandoning for retry", workId); |
| @@ -18,13 +26,18 @@ public static T ToValueOfType<T>(this RedisValue redisValue, ISerializer seriali | |||
| if (type == TypeHelper.BoolType || type == TypeHelper.StringType || type.IsNumeric()) | |||
| value = (T)Convert.ChangeType(redisValue, type); | |||
| else if (type == TypeHelper.NullableBoolType || type.IsNullableNumeric()) | |||
| value = redisValue.IsNull ? default : (T)Convert.ChangeType(redisValue, Nullable.GetUnderlyingType(type)); | |||
| value = redisValue.IsNull ? default! : (T)Convert.ChangeType(redisValue, Nullable.GetUnderlyingType(type)!); | |||
| else | |||
| return serializer.Deserialize<T>((byte[])redisValue); | |||
| return serializer.Deserialize<T>((byte[])redisValue!); | |||
|
|
|||
There was a problem hiding this comment.
The XML docs claim ToValueOfType<T> “handles null Redis values”, but the implementation doesn’t check redisValue.IsNull (or the "@@null" sentinel) and will throw if called with a null Redis value for primitive targets. Either update the docs to reflect that callers must filter HasValue/sentinel values before calling, or add explicit handling in the method so the documented behavior matches implementation.
| _cache = null!; | ||
| _messageBus = null!; |
There was a problem hiding this comment.
Fixed. Constructor now throws InvalidOperationException via null-coalescing throw when GetMuxer returns null, instead of silently assigning null!. Also ran dotnet format which fixed indentation in RedisQueueTests.cs.
Summary
Changes
Test plan