Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/how-to/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,76 @@ That single registration replaces the default JSON serializer for every cache th
- **Schema evolution is your problem.** The library does not version cached payloads. If your serializer can't deserialize an older payload after a deploy, `TryDeserialize` should return `false` and the cache will treat that as a miss; the generator will run and write a fresh entry.
- **Consider `RedisValue` directly.** `RedisValue` can hold strings, bytes, or numbers without conversion. A binary serializer (MessagePack, ProtoBuf, MemoryPack) should write `byte[]` directly via `RedisValue` implicit conversion rather than going through `string`/Base64 — JSON-style proxies can stay string-based.

### Byte-oriented serializers (`ISerializerProxy<byte[]>`)

The distributed cache path (`AddDistributedCache`) serializes through `ISerializerProxy<byte[]>`
instead of `ISerializerProxy<RedisValue>`. The default, `SystemJsonByteSerializerProxy`, passes
`byte[]` payloads through raw — no base64, no JSON, no extra encoding layer — and JSON-encodes
every other type; the requested type argument decides, there is no format sniffing. Note that the
distributed cache still wraps payloads in its own binary envelope, so the stored values are not
interchangeable with other `IDistributedCache` implementations.

Swapping it follows the same pattern as the `RedisValue` proxy — and is simpler, because most
binary serializers natively produce `byte[]`:

```csharp
public sealed class MessagePackByteSerializerProxy(MessagePackSerializerOptions? options = null)
: ISerializerProxy<byte[]>
{
public byte[]? Serialize(object? value) =>
value is null ? null : MessagePackSerializer.Serialize(value.GetType(), value, options);

public T? Deserialize<T>(byte[]? value) =>
value is null or { Length: 0 } ? default : MessagePackSerializer.Deserialize<T>(value, options);

public bool TryDeserialize<T>(string? value, out T? result)
{
result = default;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
try
{
result = MessagePackSerializer.Deserialize<T>(Convert.FromBase64String(value), options);
return true;
}
catch
{
return false;
}
}

public bool TryDeserialize<T>(object? value, out T? result)
{
result = default;
try
{
if (value is byte[] bytes)
{
result = MessagePackSerializer.Deserialize<T>(bytes, options);
return true;
}
return TryDeserialize(value?.ToString(), out result);
}
catch
{
return false;
}
}
}

services.AddSingleton<ISerializerProxy<byte[]>>(new MessagePackByteSerializerProxy());
```

Two rules for custom implementations:

1. **Round-trip `byte[]` symmetrically.** The distributed cache stores its envelope as `byte[]`;
raw passthrough (recommended) avoids per-entry encoding overhead, but any symmetric encoding
also works.
2. **This does not change the main caches' wire format** — `ICache`/`IHashCache` still serialize
through `ISerializerProxy<RedisValue>`. Swap both registrations if you want one format everywhere.

## Swapping the default factories

`ICacheFactory` and `ICachePolicyFactory` each have a default DI registration set up by `AddCaching` — the concrete `CacheFactory` and `DefaultCachePolicyFactory` respectively. When you need to substitute either, use the fluent `Use*Factory` extensions on `ICachingBuilder`. They internally call `Services.Replace(...)` so the swap survives the rest of the `AddCaching` pipeline (which uses `TryAddSingleton` and would otherwise lose to the default registration).
Expand Down
25 changes: 22 additions & 3 deletions src/UiPath.Caching.Abstractions/CacheKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,40 @@ namespace UiPath.Caching;

public readonly struct CacheKey : IEquatable<CacheKey>
{
/// <summary>Process-global casing for keys built without an explicit mode; seeded from <c>CacheOptions.KeyCasing</c>. Set only at startup.</summary>
public static CacheKeyCasing DefaultCasing { get; set; } = CacheKeyCasing.Insensitive;

public CacheKey()
: this(string.Empty)
{
}

public CacheKey(string? name) =>
Name = name?.Trim().ToLowerInvariant() ?? string.Empty;
public CacheKey(string? name)
: this(name, DefaultCasing)
{
}

public CacheKey(string? name, CacheKeyCasing casing)
{
Casing = casing;
Name = casing == CacheKeyCasing.Insensitive
? name?.Trim().ToLowerInvariant() ?? string.Empty
: name?.Trim() ?? string.Empty;
}

public string Name { get; }

/// <summary>Normalization mode this key was built with; not part of equality.</summary>
public CacheKeyCasing Casing { get; }

/// <summary>New key from <paramref name="name"/>, preserving this key's casing mode.</summary>
public CacheKey WithName(string? name) => new(name, Casing);

public override bool Equals(object? obj) =>
obj is CacheKey cacheKey && Equals(cacheKey);

public bool Equals(CacheKey other) =>
string.Equals(Name, other.Name, StringComparison.InvariantCultureIgnoreCase);
string.Equals(Name, other.Name, StringComparison.Ordinal);
Comment thread
cosmin-staicu marked this conversation as resolved.

public bool IsNull => string.IsNullOrEmpty(Name);

Expand Down
11 changes: 11 additions & 0 deletions src/UiPath.Caching.Abstractions/CacheKeyCasing.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace UiPath.Caching;

/// <summary>How <see cref="CacheKey"/> normalizes its name at construction; comparison is always ordinal.</summary>
public enum CacheKeyCasing
{
/// <summary>Trim and lowercase (invariant). The historical default.</summary>
Insensitive = 0,

/// <summary>Trim only; the caller's casing is preserved.</summary>
Sensitive = 1,
}
27 changes: 27 additions & 0 deletions src/UiPath.Caching.Abstractions/CacheKeyComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace UiPath.Caching;

/// <summary>Cached <see cref="CacheKey"/> equality comparers: <see cref="Sensitive"/> (ordinal, the struct's own equality) and <see cref="Insensitive"/> (ordinal, case-folding).</summary>
public abstract class CacheKeyComparer : EqualityComparer<CacheKey>
{
public static CacheKeyComparer Sensitive { get; } = new SensitiveComparer();

public static CacheKeyComparer Insensitive { get; } = new InsensitiveComparer();

private sealed class SensitiveComparer : CacheKeyComparer
{
public override bool Equals(CacheKey x, CacheKey y) =>
string.Equals(x.Name, y.Name, StringComparison.Ordinal);

public override int GetHashCode(CacheKey obj) =>
obj.Name is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Name);
}

private sealed class InsensitiveComparer : CacheKeyComparer
{
public override bool Equals(CacheKey x, CacheKey y) =>
string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);

public override int GetHashCode(CacheKey obj) =>
obj.Name is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name);
}
}
3 changes: 3 additions & 0 deletions src/UiPath.Caching.Abstractions/CacheOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public class CacheOptions

public bool AuditEnabled { get; set; } = true;

/// <summary>Seeds <see cref="CacheKey.DefaultCasing"/> when options bind. The distributed cache ignores this — its keys are always sensitive.</summary>
public CacheKeyCasing KeyCasing { get; set; } = CacheKeyCasing.Insensitive;

public string DefaultCache { get; set; } = KnownCacheProviderNames.InMemoryRedis;

public string DefaultTopic { get; set; } = KnownTopicNames.RedisStreams;
Expand Down
20 changes: 20 additions & 0 deletions src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,21 @@
#nullable enable
UiPath.Caching.CacheKey.CacheKey(string? name, UiPath.Caching.CacheKeyCasing casing) -> void
UiPath.Caching.CacheKey.Casing.get -> UiPath.Caching.CacheKeyCasing
UiPath.Caching.CacheKey.WithName(string? name) -> UiPath.Caching.CacheKey
UiPath.Caching.CacheKeyCasing
UiPath.Caching.CacheKeyCasing.Insensitive = 0 -> UiPath.Caching.CacheKeyCasing
UiPath.Caching.CacheKeyComparer
UiPath.Caching.CacheKeyComparer.CacheKeyComparer() -> void
static UiPath.Caching.CacheKeyComparer.Insensitive.get -> UiPath.Caching.CacheKeyComparer!
static UiPath.Caching.CacheKeyComparer.Sensitive.get -> UiPath.Caching.CacheKeyComparer!
UiPath.Caching.CacheKeyCasing.Sensitive = 1 -> UiPath.Caching.CacheKeyCasing
UiPath.Caching.CacheOptions.KeyCasing.get -> UiPath.Caching.CacheKeyCasing
UiPath.Caching.CacheOptions.KeyCasing.set -> void
UiPath.Caching.SystemJsonByteSerializerProxy
UiPath.Caching.SystemJsonByteSerializerProxy.SystemJsonByteSerializerProxy(System.Text.Json.JsonSerializerOptions? options = null) -> void
UiPath.Caching.SystemJsonByteSerializerProxy.Serialize(object? value) -> byte[]?
UiPath.Caching.SystemJsonByteSerializerProxy.Deserialize<T>(byte[]? value) -> T?
UiPath.Caching.SystemJsonByteSerializerProxy.TryDeserialize<T>(string? value, out T? result) -> bool
UiPath.Caching.SystemJsonByteSerializerProxy.TryDeserialize<T>(object? value, out T? result) -> bool
static UiPath.Caching.CacheKey.DefaultCasing.get -> UiPath.Caching.CacheKeyCasing
static UiPath.Caching.CacheKey.DefaultCasing.set -> void
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.Text.Json;

namespace UiPath.Caching;

/// <summary>JSON serializer over <c>byte[]</c>: byte payloads pass through raw, everything else is UTF-8 JSON; the type argument decides, no format sniffing.</summary>
public class SystemJsonByteSerializerProxy(JsonSerializerOptions? options = null) : ISerializerProxy<byte[]>
{
public byte[]? Serialize(object? value) => value switch
{
null => null,
byte[] bytes => bytes,
ReadOnlyMemory<byte> memory => memory.ToArray(),
_ => JsonSerializer.SerializeToUtf8Bytes(value, options),
};

public T? Deserialize<T>(byte[]? value)
{
if (value is null)
{
return default;
}
if (value is T bytes)
{
return bytes;
}
if (typeof(T) == typeof(ReadOnlyMemory<byte>))
{
return (T)(object)new ReadOnlyMemory<byte>(value);
}
if (value.Length == 0)
{
return default;
}
return JsonSerializer.Deserialize<T>(value, options);
}

public bool TryDeserialize<T>(string? value, out T? result)
{
if (string.IsNullOrWhiteSpace(value))
{
result = default;
return false;
}
try
{
result = JsonSerializer.Deserialize<T>(value, options);
return true;
}
catch
{
result = default;
return false;
}
}

public bool TryDeserialize<T>(object? value, out T? result)
{
if (value == null)
{
result = default;
return false;
}
try
{
switch (value)
{
case byte[] bytes when bytes is T typed:
result = typed;
return true;
case JsonElement jsonElement:
result = jsonElement.Deserialize<T>(options);
return true;
default:
return TryDeserialize(value.ToString() ?? string.Empty, out result);
}
}
catch
{
result = default;
return false;
}
}
}
2 changes: 2 additions & 0 deletions src/UiPath.Caching/Config/CachingBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ internal void Complete()
callback(this);
}

Services.PostConfigure<CacheOptions>(options => CacheKey.DefaultCasing = options.KeyCasing);
Services.TryAddSingleton<ISerializerProxy<RedisValue>>(sp => new SystemJsonSerializerProxy(sp.GetService<JsonSerializerOptions>()));
Services.TryAddSingleton<ISerializerProxy<byte[]>>(sp => new SystemJsonByteSerializerProxy(sp.GetService<JsonSerializerOptions>()));
Services.TryAddSingleton<IResiliencePipelineProvider>(EmptyResiliencePipelineProvider.Instance);
Services.TryAddSingleton<IChangeTokenFactory>(NullChangeTokenFactory.Instance);
Services.TryAddSingleton<ITopicFactory>(NullTopicFactory.Instance);
Expand Down
Loading
Loading