diff --git a/src/StackExchange.Redis/BitFieldEncoding.cs b/src/StackExchange.Redis/BitFieldEncoding.cs
new file mode 100644
index 000000000..eb1501fa5
--- /dev/null
+++ b/src/StackExchange.Redis/BitFieldEncoding.cs
@@ -0,0 +1,141 @@
+using System;
+
+namespace StackExchange.Redis;
+
+///
+/// The width and signedness of a single bitfield, as used by the BITFIELD operations
+/// on ; for example u8 or i53.
+///
+///
+/// Signed encodings may be 1-64 bits wide; unsigned encodings are limited to 63 bits, because the
+/// server reports every value as a signed 64-bit integer. Every legal encoding therefore fits
+/// losslessly in .
+///
+public readonly struct BitFieldEncoding : IEquatable
+{
+ // negative: signed; positive: unsigned; zero: default (not a legal encoding)
+ private readonly sbyte _value;
+
+ private BitFieldEncoding(sbyte value) => _value = value;
+
+ /// A signed bitfield bits wide; 1-64.
+ /// The width of the field, in bits.
+ public static BitFieldEncoding Signed(int width)
+ {
+ if (width is < 1 or > 64)
+ {
+ throw new ArgumentOutOfRangeException(nameof(width), "Signed bitfield encodings must be 1-64 bits wide.");
+ }
+
+ return new((sbyte)-width);
+ }
+
+ /// An unsigned bitfield bits wide; 1-63.
+ /// The width of the field, in bits.
+ public static BitFieldEncoding Unsigned(int width)
+ {
+ if (width is < 1 or > 63)
+ {
+ // the ceiling is 63 rather than 64 because every value comes back as a signed 64-bit
+ // integer, so u64 has nowhere to go; the server rejects it for the same reason
+ var message = width >= 64
+ ? "Unsigned bitfield encodings are limited to 63 bits, because the reply is a signed 64-bit integer; use UInt63 or Int64."
+ : "Unsigned bitfield encodings must be 1-63 bits wide.";
+ throw new ArgumentOutOfRangeException(nameof(width), message);
+ }
+
+ return new((sbyte)width);
+ }
+
+ /// A signed 8-bit field, i8.
+ public static BitFieldEncoding Int8 => new(-8);
+
+ /// A signed 16-bit field, i16.
+ public static BitFieldEncoding Int16 => new(-16);
+
+ /// A signed 32-bit field, i32.
+ public static BitFieldEncoding Int32 => new(-32);
+
+ /// A signed 64-bit field, i64; the widest field the server supports.
+ public static BitFieldEncoding Int64 => new(-64);
+
+ /// An unsigned 8-bit field, u8.
+ public static BitFieldEncoding UInt8 => new(8);
+
+ /// An unsigned 16-bit field, u16.
+ public static BitFieldEncoding UInt16 => new(16);
+
+ /// An unsigned 32-bit field, u32.
+ public static BitFieldEncoding UInt32 => new(32);
+
+ ///
+ /// An unsigned 63-bit field, u63; the widest unsigned field the server supports. There is
+ /// deliberately no UInt64: the reply is a signed 64-bit integer, so u64 could not be
+ /// represented, and the server rejects it.
+ ///
+ public static BitFieldEncoding UInt63 => new(63);
+
+ /// The width of the field, in bits.
+ public int Width => _value < 0 ? -_value : _value;
+
+ /// Whether the field is signed.
+ public bool IsSigned => _value < 0;
+
+ internal bool IsDefault => _value == 0;
+
+ ///
+ /// Writes this encoding as a complete bulk string - $2\r\ni8\r\n or $3\r\ni64\r\n - since
+ /// the width is 1-64 and so the length prefix is always a single digit.
+ ///
+ internal void Write(in MessageWriter writer, Span scratch)
+ {
+ if (_value == 0) ThrowDefault();
+
+ int width = Width, len;
+ scratch[0] = (byte)'$';
+ scratch[2] = (byte)'\r';
+ scratch[3] = (byte)'\n';
+ scratch[4] = _value < 0 ? (byte)'i' : (byte)'u';
+ if (width >= 10)
+ {
+ scratch[1] = (byte)'3';
+ scratch[5] = (byte)('0' + (width / 10));
+ scratch[6] = (byte)('0' + (width % 10));
+ len = 7;
+ }
+ else
+ {
+ scratch[1] = (byte)'2';
+ scratch[5] = (byte)('0' + width);
+ len = 6;
+ }
+
+ scratch[len++] = (byte)'\r';
+ scratch[len++] = (byte)'\n';
+ writer.WriteRaw(scratch.Slice(0, len));
+
+ static void ThrowDefault() => throw new ArgumentException(
+ $"A {nameof(BitFieldEncoding)} must be created via {nameof(Signed)}, {nameof(Unsigned)}, or one of the named encodings.",
+ nameof(BitFieldEncoding));
+ }
+
+ ///
+ public override string ToString() => _value == 0
+ ? "(default)"
+ : (IsSigned ? "i" : "u") + Width.ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ ///
+ public bool Equals(BitFieldEncoding other) => _value == other._value;
+
+ ///
+ public override bool Equals(object? obj) => obj is BitFieldEncoding other && Equals(other);
+
+ ///
+ public override int GetHashCode() => _value;
+
+ /// Compares two values for equality.
+ public static bool operator ==(BitFieldEncoding x, BitFieldEncoding y) => x._value == y._value;
+
+ /// Compares two values for non-equality.
+ public static bool operator !=(BitFieldEncoding x, BitFieldEncoding y) => x._value != y._value;
+}
diff --git a/src/StackExchange.Redis/BitFieldOffset.cs b/src/StackExchange.Redis/BitFieldOffset.cs
new file mode 100644
index 000000000..517192fb1
--- /dev/null
+++ b/src/StackExchange.Redis/BitFieldOffset.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Globalization;
+
+namespace StackExchange.Redis;
+
+///
+/// Where a bitfield sits inside a string: either a bit position, or - via
+/// - the position of one element in an array of consecutive fields of
+/// the same width, which the server multiplies out for us (the # form).
+///
+///
+public readonly struct BitFieldOffset : IEquatable
+{
+ private readonly long _value;
+ private readonly bool _isElement;
+
+ private BitFieldOffset(long value, bool isIndex)
+ {
+ _value = value;
+ _isElement = isIndex;
+ }
+
+ ///
+ /// The zero-based bit position, counted from the start of the string; Bit(16) is the
+ /// seventeenth bit.
+ ///
+ /// The bit position.
+ public static BitFieldOffset Bit(long bit) => bit < 0
+ ? throw new ArgumentOutOfRangeException(nameof(bit), "A bitfield bit position cannot be negative.")
+ : new(bit, false);
+
+ ///
+ /// The zero-based position of one element in an array of consecutive fields of the encoding's
+ /// own width; the server multiplies it by the width, so Element(2) of a u8 is
+ /// bit 16.
+ ///
+ /// The element position.
+ public static BitFieldOffset Element(long element) => element < 0
+ ? throw new ArgumentOutOfRangeException(nameof(element), "A bitfield element position cannot be negative.")
+ : new(element, true);
+
+ ///
+ /// Creates a bit position; equivalent to .
+ ///
+ /// The bit position.
+ public static implicit operator BitFieldOffset(long bit) => Bit(bit);
+
+ internal void Write(in MessageWriter writer, Span scratch)
+ {
+ if (!_isElement)
+ {
+ writer.WriteBulkString(_value);
+ return;
+ }
+
+ scratch[0] = (byte)'#';
+ var len = Format.FormatInt64(_value, scratch.Slice(1)) + 1;
+ writer.WriteBulkString(scratch.Slice(0, len));
+ }
+
+ ///
+ public override string ToString() => _isElement
+ ? "#" + _value.ToString(CultureInfo.InvariantCulture)
+ : _value.ToString(CultureInfo.InvariantCulture);
+
+ ///
+ public bool Equals(BitFieldOffset other) => _value == other._value && _isElement == other._isElement;
+
+ ///
+ public override bool Equals(object? obj) => obj is BitFieldOffset other && Equals(other);
+
+ ///
+ public override int GetHashCode() => _isElement ? ~_value.GetHashCode() : _value.GetHashCode();
+
+ /// Compares two values for equality.
+ public static bool operator ==(BitFieldOffset x, BitFieldOffset y) => x.Equals(y);
+
+ /// Compares two values for non-equality.
+ public static bool operator !=(BitFieldOffset x, BitFieldOffset y) => !x.Equals(y);
+}
diff --git a/src/StackExchange.Redis/BitFieldOperation.Message.cs b/src/StackExchange.Redis/BitFieldOperation.Message.cs
new file mode 100644
index 000000000..4b2ba6b60
--- /dev/null
+++ b/src/StackExchange.Redis/BitFieldOperation.Message.cs
@@ -0,0 +1,204 @@
+using System;
+
+namespace StackExchange.Redis;
+
+internal partial class RedisDatabase
+{
+ ///
+ /// BITFIELD/BITFIELD_RO: the key, then one clause per operation.
+ ///
+ ///
+ /// The OVERFLOW token is sticky server-side, and is neither reset nor consumed by a
+ /// GET, so it is written only when the required mode changes - and never for a leading run
+ /// of , which is the server's own default.
+ ///
+ internal abstract class BitFieldMessageBase : Message.CommandKeyBase
+ {
+ protected BitFieldMessageBase(int db, CommandFlags flags, RedisCommand command, in RedisKey key)
+ : base(db, flags, command, key)
+ {
+ }
+
+ ///
+ /// Accumulates the arguments needed by one operation, tracking the sticky overflow mode;
+ /// if it cannot be written.
+ ///
+ protected static bool TryCountArgs(in BitFieldOperation operation, ref BitFieldOverflow overflow, ref int count)
+ {
+ switch (operation.Kind)
+ {
+ case BitFieldOperation.OperationKind.Get:
+ count += 3; // GET, encoding, offset
+ return true;
+ case BitFieldOperation.OperationKind.Set:
+ case BitFieldOperation.OperationKind.IncrementBy:
+ if (operation.Overflow != overflow)
+ {
+ overflow = operation.Overflow;
+ count += 2; // OVERFLOW, mode
+ }
+
+ count += 4; // SET/INCRBY, encoding, offset, value
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Counts the arguments needed by a set of operations, including the key;
+ /// if any of them cannot be written.
+ ///
+ protected static bool TryCountArgs(ReadOnlySpan operations, out int count)
+ {
+ count = 1; // the key
+ var overflow = BitFieldOverflow.Wrap;
+ foreach (ref readonly var op in operations)
+ {
+ if (!TryCountArgs(in op, ref overflow, ref count)) return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Counts the arguments, throwing at the caller - rather than mid-write - if the operations
+ /// cannot be written.
+ ///
+ protected static int CountArgs(ReadOnlySpan operations, string paramName) =>
+ TryCountArgs(operations, out var count) ? count : throw InvalidOperation(paramName);
+
+ protected static ArgumentException InvalidOperation(string paramName) =>
+ new($"A default {nameof(BitFieldOperation)} is not a valid operation.", paramName);
+
+ protected static void WriteOperations(in MessageWriter writer, ReadOnlySpan operations)
+ {
+ // enough for the widest thing either part writes: a framed encoding ("$3\r\ni64\r\n", 9) or
+ // an element offset payload ('#' plus an int64, 21)
+ Span scratch = stackalloc byte[Format.MaxInt64TextLen + 1];
+ var overflow = BitFieldOverflow.Wrap;
+ foreach (ref readonly var op in operations)
+ {
+ WriteOperation(in writer, in op, ref overflow, scratch);
+ }
+ }
+
+ ///
+ /// Writes one operation, emitting the OVERFLOW token only when the sticky mode changes.
+ ///
+ protected static void WriteOperation(in MessageWriter writer, in BitFieldOperation operation, ref BitFieldOverflow overflow, Span scratch)
+ {
+ if (operation.Kind != BitFieldOperation.OperationKind.Get && operation.Overflow != overflow)
+ {
+ overflow = operation.Overflow;
+ writer.WriteRaw("$8\r\nOVERFLOW\r\n"u8);
+ switch (overflow)
+ {
+ case BitFieldOverflow.Saturate:
+ writer.WriteRaw("$3\r\nSAT\r\n"u8);
+ break;
+ case BitFieldOverflow.Fail:
+ writer.WriteRaw("$4\r\nFAIL\r\n"u8);
+ break;
+ default:
+ // shape-neutral (the count comes from the transition, not the mode), so
+ // the server's own default is the safe answer here
+ writer.WriteRaw("$4\r\nWRAP\r\n"u8);
+ break;
+ }
+ }
+
+ switch (operation.Kind)
+ {
+ case BitFieldOperation.OperationKind.Get:
+ writer.WriteRaw("$3\r\nGET\r\n"u8);
+ break;
+ case BitFieldOperation.OperationKind.Set:
+ writer.WriteRaw("$3\r\nSET\r\n"u8);
+ break;
+ case BitFieldOperation.OperationKind.IncrementBy:
+ writer.WriteRaw("$6\r\nINCRBY\r\n"u8);
+ break;
+ default:
+ // unreachable: the callers check the operations before writing the header.
+ // Guessing here would be worse than failing - a wrong sub-command corrupts
+ // data, and one of the wrong arity corrupts the connection
+ throw new InvalidOperationException($"A default {nameof(BitFieldOperation)} is not a valid operation.");
+ }
+
+ operation.Encoding.Write(in writer, scratch);
+ operation.Offset.Write(in writer, scratch);
+ if (operation.Kind != BitFieldOperation.OperationKind.Get)
+ {
+ writer.WriteBulkString(operation.Value);
+ }
+ }
+ }
+
+ ///
+ internal sealed class BitFieldMessage : BitFieldMessageBase
+ {
+ private readonly ReadOnlyMemory _operations;
+ private readonly int _argCount;
+
+ public BitFieldMessage(int db, CommandFlags flags, RedisCommand command, in RedisKey key, ReadOnlyMemory operations)
+ : base(db, flags, command, key)
+ {
+ _operations = operations;
+ _argCount = CountArgs(operations.Span, nameof(operations));
+ }
+
+ protected override void WriteImpl(in MessageWriter writer)
+ {
+ // we alias the caller's memory rather than copying it, and here - unlike the messages that
+ // hold a RedisValue[] - the *shape* depends on the contents, so a mutation between issue
+ // and write would desync the frame. Re-check before the header goes out, so that a
+ // violated contract fails with nothing written rather than corrupting the connection.
+ // Note this becomes redundant once the write path serializes on the calling thread: with
+ // no deferral there is no interval in which reasonable code could mutate the operations
+ var operations = _operations.Span;
+ if (!TryCountArgs(operations, out var count) || count != _argCount)
+ {
+ throw new InvalidOperationException($"The {nameof(BitFieldOperation)} values were modified after the command was issued.");
+ }
+
+ writer.WriteHeader(Command, _argCount);
+ writer.Write(Key);
+ WriteOperations(in writer, operations);
+ }
+
+ public override int ArgCount => _argCount;
+ }
+
+ ///
+ internal sealed class BitFieldSingleMessage : BitFieldMessageBase
+ {
+ private readonly BitFieldOperation _operation;
+ private readonly int _argCount;
+
+ public BitFieldSingleMessage(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in BitFieldOperation operation)
+ : base(db, flags, command, key)
+ {
+ _operation = operation;
+
+ // deliberately not `[operation]`: a collection expression of one element compiles to a
+ // heap array on the target frameworks without the by-ref span constructor, which would
+ // undo the point of holding the operation inline
+ _argCount = 1; // the key
+ var overflow = BitFieldOverflow.Wrap;
+ if (!TryCountArgs(in operation, ref overflow, ref _argCount)) throw InvalidOperation(nameof(operation));
+ }
+
+ protected override void WriteImpl(in MessageWriter writer)
+ {
+ writer.WriteHeader(Command, _argCount);
+ writer.Write(Key);
+
+ Span scratch = stackalloc byte[Format.MaxInt64TextLen + 1];
+ var overflow = BitFieldOverflow.Wrap;
+ WriteOperation(in writer, in _operation, ref overflow, scratch);
+ }
+
+ public override int ArgCount => _argCount;
+ }
+}
diff --git a/src/StackExchange.Redis/BitFieldOperation.cs b/src/StackExchange.Redis/BitFieldOperation.cs
new file mode 100644
index 000000000..13234122b
--- /dev/null
+++ b/src/StackExchange.Redis/BitFieldOperation.cs
@@ -0,0 +1,122 @@
+using System;
+
+namespace StackExchange.Redis;
+
+///
+/// A single BITFIELD sub-operation; see , and
+/// .
+///
+///
+public readonly struct BitFieldOperation : IEquatable
+{
+ internal enum OperationKind : byte
+ {
+ None = 0,
+ Get,
+ Set,
+ IncrementBy,
+ }
+
+ private readonly BitFieldOffset _offset;
+ private readonly BitFieldEncoding _encoding;
+ private readonly long _value;
+ private readonly BitFieldOverflow _overflow;
+ private readonly OperationKind _kind;
+
+ private BitFieldOperation(OperationKind kind, BitFieldEncoding encoding, BitFieldOffset offset, long value, BitFieldOverflow overflow)
+ {
+ if (encoding.IsDefault)
+ {
+ throw new ArgumentException(
+ $"A {nameof(BitFieldEncoding)} must be created via {nameof(BitFieldEncoding)}.{nameof(BitFieldEncoding.Signed)}, {nameof(BitFieldEncoding.Unsigned)}, or one of the named encodings.",
+ nameof(encoding));
+ }
+ if (overflow is < BitFieldOverflow.Wrap or > BitFieldOverflow.Fail)
+ {
+ throw new ArgumentOutOfRangeException(nameof(overflow));
+ }
+
+ _kind = kind;
+ _encoding = encoding;
+ _offset = offset;
+ _value = value;
+ _overflow = overflow;
+ }
+
+ ///
+ /// Reads the field at . A field that lies beyond the end of the string
+ /// reads as zero, and no applies.
+ ///
+ /// The width and signedness of the field.
+ /// The location of the field.
+ public static BitFieldOperation Get(BitFieldEncoding encoding, BitFieldOffset offset) =>
+ new(OperationKind.Get, encoding, offset, 0, BitFieldOverflow.Wrap);
+
+ ///
+ /// Writes to the field at , returning the
+ /// previous value; the string is zero-extended as needed.
+ ///
+ /// The width and signedness of the field.
+ /// The location of the field.
+ /// The value to write.
+ /// How to handle a value that does not fit the encoding.
+ public static BitFieldOperation Set(BitFieldEncoding encoding, BitFieldOffset offset, long value, BitFieldOverflow overflow = BitFieldOverflow.Wrap) =>
+ new(OperationKind.Set, encoding, offset, value, overflow);
+
+ ///
+ /// Increments the field at by (which may be
+ /// negative), returning the new value; the string is zero-extended as needed.
+ ///
+ /// The width and signedness of the field.
+ /// The location of the field.
+ /// The amount to increment by.
+ /// How to handle an increment that does not fit the encoding.
+ public static BitFieldOperation IncrementBy(BitFieldEncoding encoding, BitFieldOffset offset, long value, BitFieldOverflow overflow = BitFieldOverflow.Wrap) =>
+ new(OperationKind.IncrementBy, encoding, offset, value, overflow);
+
+ internal OperationKind Kind => _kind;
+
+ internal BitFieldEncoding Encoding => _encoding;
+
+ internal BitFieldOffset Offset => _offset;
+
+ internal long Value => _value;
+
+ ///
+ /// The overflow behavior of this operation; for a
+ /// , which cannot overflow.
+ ///
+ internal BitFieldOverflow Overflow => _overflow;
+
+ ///
+ public override string ToString() => _kind switch
+ {
+ OperationKind.Get => $"GET {_encoding} {_offset}",
+ OperationKind.Set => $"SET {_encoding} {_offset} {_value} ({_overflow})",
+ OperationKind.IncrementBy => $"INCRBY {_encoding} {_offset} {_value} ({_overflow})",
+ _ => "(default)",
+ };
+
+ ///
+ public bool Equals(BitFieldOperation other) =>
+ _kind == other._kind && _encoding == other._encoding && _offset == other._offset
+ && _value == other._value && _overflow == other._overflow;
+
+ ///
+ public override bool Equals(object? obj) => obj is BitFieldOperation other && Equals(other);
+
+ ///
+ public override int GetHashCode()
+ {
+ var hash = ((int)_kind * 397) ^ ((int)_overflow * 31);
+ hash = (hash * 397) ^ _encoding.GetHashCode();
+ hash = (hash * 397) ^ _offset.GetHashCode();
+ return (hash * 397) ^ _value.GetHashCode();
+ }
+
+ /// Compares two values for equality.
+ public static bool operator ==(BitFieldOperation x, BitFieldOperation y) => x.Equals(y);
+
+ /// Compares two values for non-equality.
+ public static bool operator !=(BitFieldOperation x, BitFieldOperation y) => !x.Equals(y);
+}
diff --git a/src/StackExchange.Redis/Enums/BitFieldOverflow.cs b/src/StackExchange.Redis/Enums/BitFieldOverflow.cs
new file mode 100644
index 000000000..35a149e13
--- /dev/null
+++ b/src/StackExchange.Redis/Enums/BitFieldOverflow.cs
@@ -0,0 +1,24 @@
+namespace StackExchange.Redis;
+
+///
+/// How BITFIELD should behave when a SET or INCRBY operation would exceed the
+/// range of the target .
+///
+///
+public enum BitFieldOverflow
+{
+ ///
+ /// Wrap around, using the usual two's-complement arithmetic; this is the server default.
+ ///
+ Wrap,
+
+ ///
+ /// Saturate at the minimum or maximum value of the encoding.
+ ///
+ Saturate,
+
+ ///
+ /// Perform no operation at all, reporting no value for the failed operation.
+ ///
+ Fail,
+}
diff --git a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs
index 7f5e268ae..d13417492 100644
--- a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs
+++ b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs
@@ -1,4 +1,4 @@
-namespace StackExchange.Redis;
+namespace StackExchange.Redis;
internal static class CommandFlagsExtensions
{
@@ -159,6 +159,7 @@ static CommandFlags DefaultCategory(RedisCommand command)
case RedisCommand.ZUNION:
case RedisCommand.ZINTERCARD:
case RedisCommand.BITCOUNT:
+ case RedisCommand.BITFIELD_RO:
case RedisCommand.BITPOS:
case RedisCommand.GETBIT:
case RedisCommand.PFCOUNT:
@@ -287,6 +288,7 @@ static CommandFlags DefaultCategory(RedisCommand command)
case RedisCommand.ZMPOP:
case RedisCommand.XADD: // explicit ids and IDMP/IDMPAUTO are demoted where we can see the args
case RedisCommand.PFMERGE: // destination accumulates union each call
+ case RedisCommand.BITFIELD: // INCRBY sub-ops compound; demoted to last-wins when the payload has none
return CommandFlags.CommandRetryWriteAccumulating;
// ==========================================================================
diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs
index d3e3d44a9..9f1eaa193 100644
--- a/src/StackExchange.Redis/Enums/RedisCommand.cs
+++ b/src/StackExchange.Redis/Enums/RedisCommand.cs
@@ -35,6 +35,10 @@ internal enum RedisCommand
BGREWRITEAOF,
BGSAVE,
BITCOUNT,
+ [AsciiHash("BITFIELD")]
+ BITFIELD,
+ [AsciiHash("BITFIELD_RO")]
+ BITFIELD_RO,
BITOP,
BITPOS,
BLPOP,
@@ -346,6 +350,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command)
case RedisCommand.ARRING:
case RedisCommand.ARSEEK:
case RedisCommand.ARSET:
+ case RedisCommand.BITFIELD:
case RedisCommand.BITOP:
case RedisCommand.BLPOP:
case RedisCommand.BRPOP:
@@ -462,6 +467,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command)
case RedisCommand.BGREWRITEAOF:
case RedisCommand.BGSAVE:
case RedisCommand.BITCOUNT:
+ case RedisCommand.BITFIELD_RO:
case RedisCommand.BITPOS:
case RedisCommand.CLIENT:
case RedisCommand.CLUSTER:
diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs
index 141ed8bf4..96ab6142e 100644
--- a/src/StackExchange.Redis/Interfaces/IDatabase.cs
+++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs
@@ -3351,6 +3351,47 @@ IEnumerable SortedSetScan(
///
long StringBitCount(RedisKey key, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None);
+ ///
+ /// Performs a single arbitrary-width integer operation on the string at ,
+ /// treating it as an array of bitfields.
+ ///
+ /// The key of the string.
+ /// The operation to perform.
+ /// The flags to use for this operation.
+ ///
+ /// The value read (for ), the previous value (for
+ /// ) or the new value (for
+ /// ); if the operation was
+ /// skipped because of .
+ ///
+ ///
+#pragma warning disable RS0026 // competing overloads - disambiguated via parameter types
+ long? StringBitField(RedisKey key, BitFieldOperation operation, CommandFlags flags = CommandFlags.None);
+
+ ///
+ /// Performs a batch of arbitrary-width integer operations on the string at ,
+ /// treating it as an array of bitfields. The operations are applied in order, and the result has
+ /// one element per operation.
+ ///
+ /// The key of the string.
+ /// The operations to perform; an empty batch is a no-op.
+ /// The flags to use for this operation.
+ ///
+ /// One element per operation, in order; an element is if that operation was
+ /// skipped because of . Empty for an empty batch, or when the
+ /// result is not observable (). The caller owns the lease.
+ ///
+ ///
+ ///
+ ///
+ /// If every operation is a this is issued as BITFIELD_RO
+ /// where the server supports it (6.0 and above), so it can be served by a replica; otherwise it is
+ /// issued as BITFIELD, which the server treats as a write and will only accept on a primary.
+ ///
+ ///
+ Lease StringBitField(RedisKey key, ReadOnlyMemory operations, CommandFlags flags = CommandFlags.None);
+#pragma warning restore RS0026
+
///
/// Perform a bitwise operation between multiple keys (containing string values) and store the result in the destination key.
/// The BITOP command supports four bitwise operations; note that NOT is a unary operator: the second key should be omitted in this case
@@ -3392,14 +3433,24 @@ IEnumerable SortedSetScan(
/// The key of the string.
/// True to check for the first 1 bit, false to check for the first 0 bit.
/// The position to start looking (defaults to 0).
- /// The position to stop looking (defaults to -1, unlimited).
+ /// The position to stop looking (defaults to -1, the last byte); leaves the range open-ended, which is not the same thing when is false - see the remarks.
/// In Redis 7+, we can choose if and specify a bit index or byte index (defaults to ).
/// The flags to use for this operation.
///
/// The command returns the position of the first bit set to 1 or 0 according to the request.
/// If we look for set bits(the bit argument is 1) and the string is empty or composed of just zero bytes, -1 is returned.
///
- ///
+ ///
+ ///
+ ///
+ /// When looking for a clear bit ( is false), the server treats an explicit
+ /// range differently from an open-ended one: with an explicit the search is confined
+ /// to the string, so a string of all-set bits gives -1, whereas with no end at all the result is the first
+ /// bit past the end of the string. Pass as for
+ /// the latter; that requires , since the server accepts a bit/byte index
+ /// type only after an explicit end.
+ ///
+ ///
long StringBitPosition(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None);
///
diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
index 84403ab32..b172375ab 100644
--- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
+++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
@@ -823,6 +823,14 @@ IAsyncEnumerable SortedSetScanAsync(
///
Task StringBitCountAsync(RedisKey key, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None);
+ ///
+#pragma warning disable RS0026 // competing overloads - disambiguated via parameter types
+ Task StringBitFieldAsync(RedisKey key, BitFieldOperation operation, CommandFlags flags = CommandFlags.None);
+
+ ///
+ Task> StringBitFieldAsync(RedisKey key, ReadOnlyMemory operations, CommandFlags flags = CommandFlags.None);
+#pragma warning restore RS0026
+
///
Task StringBitOperationAsync(Bitwise operation, RedisKey destination, RedisKey first, RedisKey second = default, CommandFlags flags = CommandFlags.None);
diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
index d2b1a815d..5dee9cda3 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
@@ -784,6 +784,12 @@ public Task StringBitCountAsync(RedisKey key, long start, long end, Comman
public Task StringBitCountAsync(RedisKey key, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) =>
Inner.StringBitCountAsync(ToInner(key), start, end, indexType, flags);
+ public Task StringBitFieldAsync(RedisKey key, BitFieldOperation operation, CommandFlags flags = CommandFlags.None) =>
+ Inner.StringBitFieldAsync(ToInner(key), operation, flags);
+
+ public Task> StringBitFieldAsync(RedisKey key, ReadOnlyMemory operations, CommandFlags flags = CommandFlags.None) =>
+ Inner.StringBitFieldAsync(ToInner(key), operations, flags);
+
public Task StringBitOperationAsync(Bitwise operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) =>
Inner.StringBitOperationAsync(operation, ToInner(destination), ToInner(keys), flags);
diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
index 1ba10ecca..fa7c43849 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
@@ -751,6 +751,12 @@ public long StringBitCount(RedisKey key, long start, long end, CommandFlags flag
public long StringBitCount(RedisKey key, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) =>
Inner.StringBitCount(ToInner(key), start, end, indexType, flags);
+ public long? StringBitField(RedisKey key, BitFieldOperation operation, CommandFlags flags = CommandFlags.None) =>
+ Inner.StringBitField(ToInner(key), operation, flags);
+
+ public Lease StringBitField(RedisKey key, ReadOnlyMemory operations, CommandFlags flags = CommandFlags.None) =>
+ Inner.StringBitField(ToInner(key), operations, flags);
+
public long StringBitOperation(Bitwise operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) =>
Inner.StringBitOperation(operation, ToInner(destination), ToInner(keys), flags);
diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
index 3e6df8527..419c74dc2 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
@@ -58,3 +58,56 @@ StackExchange.Redis.StreamAddOptions.MinId.init -> void
StackExchange.Redis.StreamAddOptions.StreamAddOptions() -> void
StackExchange.Redis.StreamAddOptions.TrimMode.get -> StackExchange.Redis.StreamTrimMode
StackExchange.Redis.StreamAddOptions.TrimMode.init -> void
+StackExchange.Redis.StringIndex
+const StackExchange.Redis.StringIndex.Unbounded = -9223372036854775808 -> long
+override StackExchange.Redis.BitFieldEncoding.Equals(object? obj) -> bool
+override StackExchange.Redis.BitFieldEncoding.GetHashCode() -> int
+override StackExchange.Redis.BitFieldEncoding.ToString() -> string!
+override StackExchange.Redis.BitFieldOffset.Equals(object? obj) -> bool
+override StackExchange.Redis.BitFieldOffset.GetHashCode() -> int
+override StackExchange.Redis.BitFieldOffset.ToString() -> string!
+override StackExchange.Redis.BitFieldOperation.Equals(object? obj) -> bool
+override StackExchange.Redis.BitFieldOperation.GetHashCode() -> int
+override StackExchange.Redis.BitFieldOperation.ToString() -> string!
+StackExchange.Redis.BitFieldEncoding
+StackExchange.Redis.BitFieldEncoding.BitFieldEncoding() -> void
+StackExchange.Redis.BitFieldEncoding.Equals(StackExchange.Redis.BitFieldEncoding other) -> bool
+StackExchange.Redis.BitFieldEncoding.IsSigned.get -> bool
+StackExchange.Redis.BitFieldEncoding.Width.get -> int
+StackExchange.Redis.BitFieldOffset
+StackExchange.Redis.BitFieldOffset.BitFieldOffset() -> void
+StackExchange.Redis.BitFieldOffset.Equals(StackExchange.Redis.BitFieldOffset other) -> bool
+StackExchange.Redis.BitFieldOperation
+StackExchange.Redis.BitFieldOperation.BitFieldOperation() -> void
+StackExchange.Redis.BitFieldOperation.Equals(StackExchange.Redis.BitFieldOperation other) -> bool
+StackExchange.Redis.BitFieldOverflow
+StackExchange.Redis.BitFieldOverflow.Fail = 2 -> StackExchange.Redis.BitFieldOverflow
+StackExchange.Redis.BitFieldOverflow.Saturate = 1 -> StackExchange.Redis.BitFieldOverflow
+StackExchange.Redis.BitFieldOverflow.Wrap = 0 -> StackExchange.Redis.BitFieldOverflow
+StackExchange.Redis.IDatabaseAsync.StringBitFieldAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.BitFieldOperation operation, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+StackExchange.Redis.IDatabaseAsync.StringBitFieldAsync(StackExchange.Redis.RedisKey key, System.ReadOnlyMemory operations, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!>!
+StackExchange.Redis.IDatabase.StringBitField(StackExchange.Redis.RedisKey key, StackExchange.Redis.BitFieldOperation operation, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long?
+StackExchange.Redis.IDatabase.StringBitField(StackExchange.Redis.RedisKey key, System.ReadOnlyMemory operations, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.Lease!
+StackExchange.Redis.RedisFeatures.BitFieldReadOnly.get -> bool
+static StackExchange.Redis.BitFieldEncoding.Int16.get -> StackExchange.Redis.BitFieldEncoding
+static StackExchange.Redis.BitFieldEncoding.Int32.get -> StackExchange.Redis.BitFieldEncoding
+static StackExchange.Redis.BitFieldEncoding.Int64.get -> StackExchange.Redis.BitFieldEncoding
+static StackExchange.Redis.BitFieldEncoding.Int8.get -> StackExchange.Redis.BitFieldEncoding
+static StackExchange.Redis.BitFieldEncoding.operator ==(StackExchange.Redis.BitFieldEncoding x, StackExchange.Redis.BitFieldEncoding y) -> bool
+static StackExchange.Redis.BitFieldEncoding.operator !=(StackExchange.Redis.BitFieldEncoding x, StackExchange.Redis.BitFieldEncoding y) -> bool
+static StackExchange.Redis.BitFieldEncoding.Signed(int width) -> StackExchange.Redis.BitFieldEncoding
+static StackExchange.Redis.BitFieldEncoding.UInt16.get -> StackExchange.Redis.BitFieldEncoding
+static StackExchange.Redis.BitFieldEncoding.UInt32.get -> StackExchange.Redis.BitFieldEncoding
+static StackExchange.Redis.BitFieldEncoding.UInt63.get -> StackExchange.Redis.BitFieldEncoding
+static StackExchange.Redis.BitFieldEncoding.UInt8.get -> StackExchange.Redis.BitFieldEncoding
+static StackExchange.Redis.BitFieldEncoding.Unsigned(int width) -> StackExchange.Redis.BitFieldEncoding
+static StackExchange.Redis.BitFieldOffset.Bit(long bit) -> StackExchange.Redis.BitFieldOffset
+static StackExchange.Redis.BitFieldOffset.implicit operator StackExchange.Redis.BitFieldOffset(long bit) -> StackExchange.Redis.BitFieldOffset
+static StackExchange.Redis.BitFieldOffset.Element(long element) -> StackExchange.Redis.BitFieldOffset
+static StackExchange.Redis.BitFieldOffset.operator ==(StackExchange.Redis.BitFieldOffset x, StackExchange.Redis.BitFieldOffset y) -> bool
+static StackExchange.Redis.BitFieldOffset.operator !=(StackExchange.Redis.BitFieldOffset x, StackExchange.Redis.BitFieldOffset y) -> bool
+static StackExchange.Redis.BitFieldOperation.Get(StackExchange.Redis.BitFieldEncoding encoding, StackExchange.Redis.BitFieldOffset offset) -> StackExchange.Redis.BitFieldOperation
+static StackExchange.Redis.BitFieldOperation.IncrementBy(StackExchange.Redis.BitFieldEncoding encoding, StackExchange.Redis.BitFieldOffset offset, long value, StackExchange.Redis.BitFieldOverflow overflow = StackExchange.Redis.BitFieldOverflow.Wrap) -> StackExchange.Redis.BitFieldOperation
+static StackExchange.Redis.BitFieldOperation.operator ==(StackExchange.Redis.BitFieldOperation x, StackExchange.Redis.BitFieldOperation y) -> bool
+static StackExchange.Redis.BitFieldOperation.operator !=(StackExchange.Redis.BitFieldOperation x, StackExchange.Redis.BitFieldOperation y) -> bool
+static StackExchange.Redis.BitFieldOperation.Set(StackExchange.Redis.BitFieldEncoding encoding, StackExchange.Redis.BitFieldOffset offset, long value, StackExchange.Redis.BitFieldOverflow overflow = StackExchange.Redis.BitFieldOverflow.Wrap) -> StackExchange.Redis.BitFieldOperation
diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs
index 172dd3c8d..011c38c80 100644
--- a/src/StackExchange.Redis/RedisDatabase.cs
+++ b/src/StackExchange.Redis/RedisDatabase.cs
@@ -3761,6 +3761,32 @@ public Task StringBitCountAsync(RedisKey key, long start = 0, long end = -
return ExecuteAsync(msg, ResultProcessor.Int64);
}
+ public long? StringBitField(RedisKey key, BitFieldOperation operation, CommandFlags flags = CommandFlags.None)
+ {
+ var msg = GetBitFieldMessage(key, operation, flags, out var server);
+ return ExecuteSync(msg, ResultProcessor.NullableInt64, server);
+ }
+
+ public Task StringBitFieldAsync(RedisKey key, BitFieldOperation operation, CommandFlags flags = CommandFlags.None)
+ {
+ var msg = GetBitFieldMessage(key, operation, flags, out var server);
+ return ExecuteAsync(msg, ResultProcessor.NullableInt64, server);
+ }
+
+ public Lease StringBitField(RedisKey key, ReadOnlyMemory operations, CommandFlags flags = CommandFlags.None)
+ {
+ if (operations.IsEmpty) return Lease.Empty; // no operations, no reply elements
+ var msg = GetBitFieldMessage(key, operations, flags, out var server);
+ return ExecuteSync(msg, ResultProcessor.LeaseNullableInt64, server, defaultValue: Lease.Empty);
+ }
+
+ public Task> StringBitFieldAsync(RedisKey key, ReadOnlyMemory operations, CommandFlags flags = CommandFlags.None)
+ {
+ if (operations.IsEmpty) return CompletedTask>.FromDefault(Lease.Empty, asyncState);
+ var msg = GetBitFieldMessage(key, operations, flags, out var server);
+ return ExecuteAsync(msg, ResultProcessor.LeaseNullableInt64, Lease.Empty, server);
+ }
+
public long StringBitOperation(Bitwise operation, RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None)
{
var msg = GetStringBitOperationMessage(operation, destination, first, second, flags);
@@ -3790,11 +3816,7 @@ public long StringBitPosition(RedisKey key, bool bit, long start, long end, Comm
public long StringBitPosition(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None)
{
- var msg = indexType switch
- {
- StringIndexType.Byte => Message.Create(Database, flags, RedisCommand.BITPOS, key, bit, start, end),
- _ => Message.Create(Database, flags, RedisCommand.BITPOS, key, bit, start, end, indexType.ToLiteral()),
- };
+ var msg = GetStringBitPositionMessage(key, bit, start, end, indexType, flags);
return ExecuteSync(msg, ResultProcessor.Int64);
}
@@ -3803,11 +3825,7 @@ public Task StringBitPositionAsync(RedisKey key, bool bit, long start, lon
public Task StringBitPositionAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None)
{
- var msg = indexType switch
- {
- StringIndexType.Byte => Message.Create(Database, flags, RedisCommand.BITPOS, key, bit, start, end),
- _ => Message.Create(Database, flags, RedisCommand.BITPOS, key, bit, start, end, indexType.ToLiteral()),
- };
+ var msg = GetStringBitPositionMessage(key, bit, start, end, indexType, flags);
return ExecuteAsync(msg, ResultProcessor.Int64);
}
@@ -5402,6 +5420,104 @@ private Message GetStringBitOperationMessage(Bitwise operation, RedisKey destina
return Message.CreateInSlot(Database, slot, flags, RedisCommand.BITOP, new[] { op, destination.AsRedisValue(), first.AsRedisValue(), second.AsRedisValue() });
}
+ private Message GetBitFieldMessage(in RedisKey key, ReadOnlyMemory operations, CommandFlags flags, out ServerEndPoint? server)
+ {
+ bool allGet = true, anyIncrement = false;
+ foreach (ref readonly var op in operations.Span)
+ {
+ switch (op.Kind)
+ {
+ case BitFieldOperation.OperationKind.Get:
+ break;
+ case BitFieldOperation.OperationKind.IncrementBy:
+ anyIncrement = true;
+ allGet = false;
+ break;
+ default:
+ allGet = false;
+ break;
+ }
+ }
+
+ var command = GetBitFieldCommand(key, allGet, anyIncrement, ref flags, out server);
+ return new BitFieldMessage(Database, flags, command, key, operations);
+ }
+
+ private Message GetBitFieldMessage(in RedisKey key, in BitFieldOperation operation, CommandFlags flags, out ServerEndPoint? server)
+ {
+ var command = GetBitFieldCommand(
+ key,
+ allGet: operation.Kind == BitFieldOperation.OperationKind.Get,
+ anyIncrement: operation.Kind == BitFieldOperation.OperationKind.IncrementBy,
+ ref flags,
+ out server);
+ return new BitFieldSingleMessage(Database, flags, command, key, operation);
+ }
+
+ private RedisCommand GetBitFieldCommand(in RedisKey key, bool allGet, bool anyIncrement, ref CommandFlags flags, out ServerEndPoint? server)
+ {
+ var readOnlyAvailable = false;
+ server = null;
+ if (allGet)
+ {
+ // every operation is a read, so BITFIELD_RO will do - and unlike BITFIELD, a replica
+ // will accept it
+ var features = GetFeatures(key, flags, RedisCommand.BITFIELD_RO, out server);
+ readOnlyAvailable = server is not null && features.BitFieldReadOnly
+ && multiplexer.CommandMap.IsAvailable(RedisCommand.BITFIELD_RO);
+ if (!readOnlyAvailable)
+ {
+ server = null; // BITFIELD is primary-only; forget the read-eligible server we picked
+ }
+ }
+
+ return SelectBitFieldCommand(allGet, anyIncrement, readOnlyAvailable, ref flags);
+ }
+
+ ///
+ /// Chooses the command, and the retry category the payload deserves - which is a separate axis
+ /// from routing: the server treats BITFIELD as a write however read-only its sub-operations are,
+ /// but that governs which servers will accept it, not whether replaying it is safe.
+ ///
+ internal static RedisCommand SelectBitFieldCommand(bool allGet, bool anyIncrement, bool readOnlyAvailable, ref CommandFlags flags)
+ {
+ if (allGet)
+ {
+ // nothing to replay, whichever of the two commands we end up issuing
+ flags = flags.WithCategory(CommandFlags.CommandRetryReadOnly);
+ return readOnlyAvailable ? RedisCommand.BITFIELD_RO : RedisCommand.BITFIELD;
+ }
+
+ if (!anyIncrement)
+ {
+ // SET is positional, so a replay lands on the same value; only INCRBY compounds
+ flags = flags.WithCategory(CommandFlags.CommandRetryWriteLastWins);
+ }
+
+ return RedisCommand.BITFIELD;
+ }
+
+ internal Message GetStringBitPositionMessage(in RedisKey key, bool bit, long start, long end, StringIndexType indexType, CommandFlags flags)
+ {
+ if (end == StringIndex.Unbounded)
+ {
+ // BITPOS takes the BYTE/BIT token only *after* an explicit end, so an open-ended range and a
+ // bit index cannot be expressed together; dropping the token silently would reinterpret start
+ if (indexType != StringIndexType.Byte)
+ {
+ throw new ArgumentException($"{nameof(StringIndex)}.{nameof(StringIndex.Unbounded)} requires {nameof(StringIndexType)}.{nameof(StringIndexType.Byte)}; the server accepts a bit/byte index type only after an explicit end.", nameof(indexType));
+ }
+
+ return Message.Create(Database, flags, RedisCommand.BITPOS, key, bit, start);
+ }
+
+ return indexType switch
+ {
+ StringIndexType.Byte => Message.Create(Database, flags, RedisCommand.BITPOS, key, bit, start, end),
+ _ => Message.Create(Database, flags, RedisCommand.BITPOS, key, bit, start, end, indexType.ToLiteral()),
+ };
+ }
+
internal Message GetStringGetExMessage(in RedisKey key, Expiration expiry, CommandFlags flags = CommandFlags.None)
{
var tokens = expiry.GetTokenCount(allowEnx: false);
diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs
index 3e30a65dc..9bfe8b27c 100644
--- a/src/StackExchange.Redis/RedisFeatures.cs
+++ b/src/StackExchange.Redis/RedisFeatures.cs
@@ -74,6 +74,11 @@ public RedisFeatures(Version version)
///
public bool BitwiseOperations => Version.IsAtLeast(v2_6_0);
+ ///
+ /// Is BITFIELD_RO available?
+ ///
+ public bool BitFieldReadOnly => Version.IsAtLeast(v6_0_0);
+
///
/// Is CLIENT SETNAME available?
///
diff --git a/src/StackExchange.Redis/ResultProcessor.Lease.cs b/src/StackExchange.Redis/ResultProcessor.Lease.cs
index 4544321fe..919c8f42f 100644
--- a/src/StackExchange.Redis/ResultProcessor.Lease.cs
+++ b/src/StackExchange.Redis/ResultProcessor.Lease.cs
@@ -8,6 +8,11 @@ internal abstract partial class ResultProcessor
// Lease result processors
public static readonly ResultProcessor?> LeaseFloat32 = new LeaseFloat32Processor();
+ // outer type is deliberately non-null: BITFIELD always replies with an array (empty for no
+ // operations), so the only null would come from a non-observable result such as fire-and-forget,
+ // which the callers handle with an explicit empty default
+ public static readonly ResultProcessor> LeaseNullableInt64 = new LeaseNullableInt64Processor()!;
+
public static readonly ResultProcessor>
Lease = new LeaseProcessor();
@@ -141,6 +146,12 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes
}
}
+ private sealed class LeaseNullableInt64Processor : LeaseProcessor
+ {
+ // BITFIELD reports a nil element for any operation skipped by OVERFLOW FAIL
+ protected override long? TryParse(ref RespReader reader) => reader.IsNull ? null : reader.ReadInt64();
+ }
+
private sealed class LeaseFloat32Processor : LeaseProcessor
{
protected override float TryParse(ref RespReader reader) => (float)reader.ReadDouble();
diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj
index 2d04242ab..587af0622 100644
--- a/src/StackExchange.Redis/StackExchange.Redis.csproj
+++ b/src/StackExchange.Redis/StackExchange.Redis.csproj
@@ -40,6 +40,7 @@
+
diff --git a/src/StackExchange.Redis/StringIndex.cs b/src/StackExchange.Redis/StringIndex.cs
new file mode 100644
index 000000000..dcf0d6ea2
--- /dev/null
+++ b/src/StackExchange.Redis/StringIndex.cs
@@ -0,0 +1,24 @@
+namespace StackExchange.Redis;
+
+///
+/// Well-known index values for the bitmap operations that take a start/end range.
+///
+public static class StringIndex
+{
+ ///
+ /// Indicates that no end index should be sent to the server, leaving the range open-ended.
+ ///
+ ///
+ ///
+ /// Valid only as the end of
+ /// (and the async equivalent), and only in combination with : BITPOS
+ /// accepts a bit/byte index type only after an explicit end.
+ ///
+ ///
+ /// This is not interchangeable with -1. When searching for a clear bit, an explicit end restricts the
+ /// search to the string itself, so an all-set string yields -1; an open-ended range instead reports the first
+ /// bit beyond the end of the string.
+ ///
+ ///
+ public const long Unbounded = long.MinValue;
+}
diff --git a/tests/StackExchange.Redis.Tests/BitFieldTypeTests.cs b/tests/StackExchange.Redis.Tests/BitFieldTypeTests.cs
new file mode 100644
index 000000000..fd768c940
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/BitFieldTypeTests.cs
@@ -0,0 +1,107 @@
+using System;
+using Xunit;
+
+namespace StackExchange.Redis.Tests;
+
+///
+/// Unit tests for the BITFIELD value types; no server required.
+///
+public class BitFieldTypeTests
+{
+ [Theory]
+ [InlineData(1, "i1")]
+ [InlineData(8, "i8")]
+ [InlineData(53, "i53")]
+ [InlineData(64, "i64")]
+ public void SignedWidths(int width, string expected)
+ {
+ var encoding = BitFieldEncoding.Signed(width);
+ Assert.Equal(expected, encoding.ToString());
+ Assert.Equal(width, encoding.Width);
+ Assert.True(encoding.IsSigned);
+ }
+
+ [Theory]
+ [InlineData(1, "u1")]
+ [InlineData(8, "u8")]
+ [InlineData(63, "u63")]
+ public void UnsignedWidths(int width, string expected)
+ {
+ var encoding = BitFieldEncoding.Unsigned(width);
+ Assert.Equal(expected, encoding.ToString());
+ Assert.Equal(width, encoding.Width);
+ Assert.False(encoding.IsSigned);
+ }
+
+ [Fact]
+ public void NamedEncodingsMatchTheirFactories()
+ {
+ Assert.Equal(BitFieldEncoding.Signed(8), BitFieldEncoding.Int8);
+ Assert.Equal(BitFieldEncoding.Signed(16), BitFieldEncoding.Int16);
+ Assert.Equal(BitFieldEncoding.Signed(32), BitFieldEncoding.Int32);
+ Assert.Equal(BitFieldEncoding.Signed(64), BitFieldEncoding.Int64);
+ Assert.Equal(BitFieldEncoding.Unsigned(8), BitFieldEncoding.UInt8);
+ Assert.Equal(BitFieldEncoding.Unsigned(16), BitFieldEncoding.UInt16);
+ Assert.Equal(BitFieldEncoding.Unsigned(32), BitFieldEncoding.UInt32);
+ Assert.Equal(BitFieldEncoding.Unsigned(63), BitFieldEncoding.UInt63);
+ Assert.NotEqual(BitFieldEncoding.Int8, BitFieldEncoding.UInt8);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(65)]
+ public void SignedRejectsIllegalWidths(int width) =>
+ Assert.Throws(() => BitFieldEncoding.Signed(width));
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(64)]
+ public void UnsignedRejectsIllegalWidths(int width) =>
+ Assert.Throws(() => BitFieldEncoding.Unsigned(width));
+
+ [Fact]
+ public void UnsignedSixtyFourExplainsItself()
+ {
+ // the obvious thing to reach for; the message should say why it cannot work, and what to use
+ var ex = Assert.Throws(() => BitFieldEncoding.Unsigned(64));
+ Assert.Contains("UInt63", ex.Message);
+ Assert.Contains("Int64", ex.Message);
+ }
+
+ [Fact]
+ public void OffsetForms()
+ {
+ Assert.Equal("100", BitFieldOffset.Bit(100).ToString());
+ Assert.Equal("#100", BitFieldOffset.Element(100).ToString());
+ Assert.Equal(BitFieldOffset.Bit(3), (BitFieldOffset)3L); // implicit long is a bit offset
+ Assert.NotEqual(BitFieldOffset.Bit(3), BitFieldOffset.Element(3));
+ }
+
+ [Fact]
+ public void OffsetsCannotBeNegative()
+ {
+ Assert.Throws(() => BitFieldOffset.Bit(-1));
+ Assert.Throws(() => BitFieldOffset.Element(-1));
+ }
+
+ [Fact]
+ public void DefaultEncodingIsRejectedAtConstruction()
+ {
+ var ex = Assert.Throws(() => BitFieldOperation.Get(default, 0));
+ Assert.Equal("encoding", ex.ParamName);
+ }
+
+ [Fact]
+ public void UnknownOverflowIsRejected() =>
+ Assert.Throws(() => BitFieldOperation.Set(BitFieldEncoding.UInt8, 0, 1, (BitFieldOverflow)42));
+
+ [Fact]
+ public void OperationToString()
+ {
+ Assert.Equal("GET u8 #1", BitFieldOperation.Get(BitFieldEncoding.UInt8, BitFieldOffset.Element(1)).ToString());
+ Assert.Equal("SET i16 32 -5 (Saturate)", BitFieldOperation.Set(BitFieldEncoding.Int16, 32, -5, BitFieldOverflow.Saturate).ToString());
+ Assert.Equal("INCRBY i8 0 1 (Wrap)", BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, 1).ToString());
+ }
+}
diff --git a/tests/StackExchange.Redis.Tests/BitTests.cs b/tests/StackExchange.Redis.Tests/BitTests.cs
index b4c032366..71f00b90d 100644
--- a/tests/StackExchange.Redis.Tests/BitTests.cs
+++ b/tests/StackExchange.Redis.Tests/BitTests.cs
@@ -1,4 +1,7 @@
-using System.Threading.Tasks;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using StackExchange.Redis.Profiling;
using Xunit;
namespace StackExchange.Redis.Tests;
@@ -18,4 +21,129 @@ public async Task BasicOps()
Assert.True(db.StringGetBit(key, 10));
Assert.False(db.StringGetBit(key, 11));
}
+
+ [Fact]
+ public async Task BitPositionUnboundedEndLooksPastEndOfString()
+ {
+ await using var conn = Create();
+ var db = conn.GetDatabase();
+ RedisKey key = Me();
+
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+ for (int i = 0; i < 8; i++)
+ {
+ db.StringSetBit(key, i, true, CommandFlags.FireAndForget);
+ }
+ Assert.Equal(1, db.StringLength(key));
+
+ // an explicit end confines the search to the string, which has no clear bit
+ Assert.Equal(-1, db.StringBitPosition(key, false));
+ Assert.Equal(-1, await db.StringBitPositionAsync(key, false, 0, -1));
+
+ // an open-ended range reports the first clear bit past the end of the string
+ Assert.Equal(8, db.StringBitPosition(key, false, 0, StringIndex.Unbounded));
+ Assert.Equal(8, await db.StringBitPositionAsync(key, false, 0, StringIndex.Unbounded));
+
+ // set bits are unaffected either way
+ Assert.Equal(0, db.StringBitPosition(key, true));
+ Assert.Equal(0, db.StringBitPosition(key, true, 0, StringIndex.Unbounded));
+ }
+
+ [Fact]
+ public async Task BitFieldBasicOps()
+ {
+ await using var conn = Create(require: RedisFeatures.v3_2_0);
+ var db = conn.GetDatabase();
+ RedisKey key = Me();
+
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+
+ // SET reports the previous value, GET the current one; the #N form indexes by width
+ Assert.Equal(0, db.StringBitField(key, BitFieldOperation.Set(BitFieldEncoding.UInt8, BitFieldOffset.Element(1), 255)));
+ Assert.Equal(255, await db.StringBitFieldAsync(key, BitFieldOperation.Get(BitFieldEncoding.UInt8, BitFieldOffset.Element(1))));
+ Assert.Equal(255, db.StringBitField(key, BitFieldOperation.Get(BitFieldEncoding.UInt8, 8))); // ... same field, by bit
+ Assert.Equal(0, db.StringBitField(key, BitFieldOperation.Get(BitFieldEncoding.UInt8, 0)));
+
+ // INCRBY reports the new value
+ Assert.Equal(-1, db.StringBitField(key, BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, -1)));
+ }
+
+ [Fact]
+ public async Task BitFieldBatchAppliesInOrder()
+ {
+ await using var conn = Create(require: RedisFeatures.v3_2_0);
+ var db = conn.GetDatabase();
+ RedisKey key = Me();
+
+ db.KeyDelete(key, CommandFlags.FireAndForget);
+
+ using var lease = await db.StringBitFieldAsync(
+ key,
+ new[]
+ {
+ BitFieldOperation.Set(BitFieldEncoding.Int8, 0, 100),
+ BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, 100, BitFieldOverflow.Saturate),
+ BitFieldOperation.Get(BitFieldEncoding.Int8, 0),
+ BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, 100, BitFieldOverflow.Fail),
+ BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, 100, BitFieldOverflow.Wrap),
+ });
+
+ // the FAIL element is null; note the sticky OVERFLOW state survives the intervening GET
+ Assert.Equal(new long?[] { 0, 127, 127, null, -29 }, lease.Span.ToArray());
+ }
+
+ [Fact]
+ public async Task BitFieldEmptyBatchIsANoOp()
+ {
+ await using var conn = Create(require: RedisFeatures.v3_2_0);
+ var db = conn.GetDatabase();
+ RedisKey key = Me();
+
+ using var lease = db.StringBitField(key, ReadOnlyMemory.Empty);
+ Assert.True(lease.IsEmpty);
+
+ using var leaseAsync = await db.StringBitFieldAsync(key, ReadOnlyMemory.Empty);
+ Assert.True(leaseAsync.IsEmpty);
+ }
+
+ [Fact]
+ public async Task BitFieldAllGetGoesOutAsReadOnlyAndReachesAReplica()
+ {
+ await using var conn = Create(
+ require: RedisFeatures.v6_0_0,
+ configuration: TestConfig.Current.PrimaryServerAndPort + "," + TestConfig.Current.ReplicaServerAndPort);
+ Assert.SkipUnless(
+ conn.GetEndPoints().Any(ep => conn.GetServer(ep).IsReplica),
+ "No replica in this configuration");
+
+ var db = conn.GetDatabase();
+ RedisKey key = Me();
+ var session = new ProfilingSession();
+ conn.RegisterProfiler(() => session);
+
+ // an all-GET batch is issued as BITFIELD_RO, so a replica will serve it; the value itself is
+ // not asserted here - reading from a replica makes that a race with replication
+ using var lease = db.StringBitField(
+ key,
+ new[] { BitFieldOperation.Get(BitFieldEncoding.UInt8, 0) },
+ CommandFlags.DemandReplica);
+ Assert.Equal(1, lease.Length);
+ Assert.NotNull(db.StringBitField(key, BitFieldOperation.Get(BitFieldEncoding.UInt8, 0), CommandFlags.DemandReplica));
+
+ // ... whereas anything that writes stays as BITFIELD
+ db.StringBitField(key, BitFieldOperation.Set(BitFieldEncoding.UInt8, 0, 1));
+
+ var commands = session.FinishProfiling().ToList();
+ foreach (var command in commands)
+ {
+ Log($"{command.Command} -> {command.EndPoint}");
+ }
+
+ var readOnly = commands.Where(c => c.Command == "BITFIELD_RO").ToList();
+ Assert.Equal(2, readOnly.Count);
+ Assert.All(readOnly, c => Assert.True(conn.GetServer(c.EndPoint).IsReplica));
+
+ var write = Assert.Single(commands, c => c.Command == "BITFIELD");
+ Assert.False(conn.GetServer(write.EndPoint).IsReplica);
+ }
}
diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BitFieldRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BitFieldRoundTrip.cs
new file mode 100644
index 000000000..8bca76ec8
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BitFieldRoundTrip.cs
@@ -0,0 +1,203 @@
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace StackExchange.Redis.Tests.RoundTripUnitTests;
+
+public class BitFieldRoundTrip(ITestOutputHelper log)
+{
+ private static Message Batch(RedisCommand command, params BitFieldOperation[] operations) =>
+ new RedisDatabase.BitFieldMessage(0, CommandFlags.None, command, (RedisKey)"k", operations);
+
+ private static Message Single(RedisCommand command, BitFieldOperation operation) =>
+ new RedisDatabase.BitFieldSingleMessage(0, CommandFlags.None, command, (RedisKey)"k", operation);
+
+ [Fact(Timeout = 1000)]
+ public async Task Increment_RoundTrips()
+ {
+ var msg = Batch(RedisCommand.BITFIELD, BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, 1));
+ const string RequestResp = "*6\r\n$8\r\nBITFIELD\r\n$1\r\nk\r\n$6\r\nINCRBY\r\n$2\r\ni8\r\n$1\r\n0\r\n$1\r\n1\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.LeaseNullableInt64, RequestResp, "*1\r\n:1\r\n", log: log);
+ Assert.Equal(new long?[] { 1 }, result.Span.ToArray());
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task SingleOperation_WritesTheSameAsAUnitBatch()
+ {
+ // the single-operation overload avoids the array, but must produce identical bytes
+ var msg = Single(RedisCommand.BITFIELD, BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, 1));
+ const string RequestResp = "*6\r\n$8\r\nBITFIELD\r\n$1\r\nk\r\n$6\r\nINCRBY\r\n$2\r\ni8\r\n$1\r\n0\r\n$1\r\n1\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.NullableInt64, RequestResp, "*1\r\n:1\r\n", log: log);
+ Assert.Equal(1, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task AllGet_UsesReadOnlyCommand()
+ {
+ var msg = Batch(
+ RedisCommand.BITFIELD_RO,
+ BitFieldOperation.Get(BitFieldEncoding.UInt8, 0),
+ BitFieldOperation.Get(BitFieldEncoding.UInt63, BitFieldOffset.Element(2)));
+ const string RequestResp = "*8\r\n$11\r\nBITFIELD_RO\r\n$1\r\nk\r\n$3\r\nGET\r\n$2\r\nu8\r\n$1\r\n0\r\n$3\r\nGET\r\n$3\r\nu63\r\n$2\r\n#2\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.LeaseNullableInt64, RequestResp, "*2\r\n:255\r\n:0\r\n", log: log);
+ Assert.Equal(new long?[] { 255, 0 }, result.Span.ToArray());
+ }
+
+ [Theory(Timeout = 1000)]
+ [InlineData(1, "$2\r\ni1\r\n")]
+ [InlineData(9, "$2\r\ni9\r\n")]
+ [InlineData(10, "$3\r\ni10\r\n")]
+ [InlineData(64, "$3\r\ni64\r\n")]
+ public async Task EncodingWidths_AreFramedCorrectly(int width, string encodingResp)
+ {
+ // the width is 1-64, so the length prefix is always one digit and the whole bulk string is
+ // written in one go
+ var msg = Batch(RedisCommand.BITFIELD_RO, BitFieldOperation.Get(BitFieldEncoding.Signed(width), 0));
+ var requestResp = "*5\r\n$11\r\nBITFIELD_RO\r\n$1\r\nk\r\n$3\r\nGET\r\n" + encodingResp + "$1\r\n0\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.LeaseNullableInt64, requestResp, "*1\r\n:0\r\n", log: log);
+ Assert.Equal(new long?[] { 0 }, result.Span.ToArray());
+ }
+
+ [Theory(Timeout = 1000)]
+ [InlineData(0, "$2\r\n#0\r\n")]
+ [InlineData(9, "$2\r\n#9\r\n")]
+ [InlineData(1234567890123, "$14\r\n#1234567890123\r\n")]
+ public async Task ElementOffsets_AreFramedCorrectly(long element, string offsetResp)
+ {
+ var msg = Batch(RedisCommand.BITFIELD_RO, BitFieldOperation.Get(BitFieldEncoding.UInt8, BitFieldOffset.Element(element)));
+ var requestResp = "*5\r\n$11\r\nBITFIELD_RO\r\n$1\r\nk\r\n$3\r\nGET\r\n$2\r\nu8\r\n" + offsetResp;
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.LeaseNullableInt64, requestResp, "*1\r\n:0\r\n", log: log);
+ Assert.Equal(new long?[] { 0 }, result.Span.ToArray());
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task LeadingWrap_IsNotEmitted()
+ {
+ // WRAP is the server default, so there is nothing to say
+ var msg = Batch(RedisCommand.BITFIELD, BitFieldOperation.Set(BitFieldEncoding.Int64, 0, 5, BitFieldOverflow.Wrap));
+ const string RequestResp = "*6\r\n$8\r\nBITFIELD\r\n$1\r\nk\r\n$3\r\nSET\r\n$3\r\ni64\r\n$1\r\n0\r\n$1\r\n5\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.LeaseNullableInt64, RequestResp, "*1\r\n:0\r\n", log: log);
+ Assert.Equal(new long?[] { 0 }, result.Span.ToArray());
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task Overflow_IsEmittedOnlyWhenItChanges()
+ {
+ var msg = Batch(
+ RedisCommand.BITFIELD,
+ BitFieldOperation.Set(BitFieldEncoding.UInt8, 0, 1, BitFieldOverflow.Saturate),
+ BitFieldOperation.IncrementBy(BitFieldEncoding.UInt8, BitFieldOffset.Element(1), 2, BitFieldOverflow.Saturate),
+ BitFieldOperation.Set(BitFieldEncoding.UInt8, 0, 3, BitFieldOverflow.Wrap));
+ const string RequestResp = "*18\r\n$8\r\nBITFIELD\r\n$1\r\nk\r\n"
+ + "$8\r\nOVERFLOW\r\n$3\r\nSAT\r\n$3\r\nSET\r\n$2\r\nu8\r\n$1\r\n0\r\n$1\r\n1\r\n"
+ + "$6\r\nINCRBY\r\n$2\r\nu8\r\n$2\r\n#1\r\n$1\r\n2\r\n"
+ + "$8\r\nOVERFLOW\r\n$4\r\nWRAP\r\n$3\r\nSET\r\n$2\r\nu8\r\n$1\r\n0\r\n$1\r\n3\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.LeaseNullableInt64, RequestResp, "*3\r\n:0\r\n:2\r\n:1\r\n", log: log);
+ Assert.Equal(new long?[] { 0, 2, 1 }, result.Span.ToArray());
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task Overflow_SurvivesAnInterveningGet_AndFailReportsNull()
+ {
+ // the sticky OVERFLOW state is not reset or consumed by a GET, so the second INCRBY needs no token
+ var msg = Batch(
+ RedisCommand.BITFIELD,
+ BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, 100, BitFieldOverflow.Fail),
+ BitFieldOperation.Get(BitFieldEncoding.Int8, 0),
+ BitFieldOperation.IncrementBy(BitFieldEncoding.Int8, 0, 100, BitFieldOverflow.Fail));
+ const string RequestResp = "*15\r\n$8\r\nBITFIELD\r\n$1\r\nk\r\n"
+ + "$8\r\nOVERFLOW\r\n$4\r\nFAIL\r\n$6\r\nINCRBY\r\n$2\r\ni8\r\n$1\r\n0\r\n$3\r\n100\r\n"
+ + "$3\r\nGET\r\n$2\r\ni8\r\n$1\r\n0\r\n"
+ + "$6\r\nINCRBY\r\n$2\r\ni8\r\n$1\r\n0\r\n$3\r\n100\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.LeaseNullableInt64, RequestResp, "*3\r\n:100\r\n:100\r\n$-1\r\n", log: log);
+ Assert.Equal(new long?[] { 100, 100, null }, result.Span.ToArray());
+ }
+
+ [Fact]
+ public void ArgCountMatchesTheOperations()
+ {
+ // key + (SET, enc, off, value)
+ Assert.Equal(5, Batch(RedisCommand.BITFIELD, BitFieldOperation.Set(BitFieldEncoding.UInt8, 0, 1)).ArgCount);
+ Assert.Equal(5, Single(RedisCommand.BITFIELD, BitFieldOperation.Set(BitFieldEncoding.UInt8, 0, 1)).ArgCount);
+
+ // key + (GET, enc, off)
+ Assert.Equal(4, Batch(RedisCommand.BITFIELD_RO, BitFieldOperation.Get(BitFieldEncoding.UInt8, 0)).ArgCount);
+
+ // key + (OVERFLOW, mode) + 2x(INCRBY, enc, off, value)
+ Assert.Equal(
+ 11,
+ Batch(
+ RedisCommand.BITFIELD,
+ BitFieldOperation.IncrementBy(BitFieldEncoding.UInt8, 0, 1, BitFieldOverflow.Fail),
+ BitFieldOperation.IncrementBy(BitFieldEncoding.UInt8, 0, 1, BitFieldOverflow.Fail)).ArgCount);
+ }
+
+ [Fact]
+ public void DefaultOperation_IsRejectedBeforeTheMessageIsQueued()
+ {
+ var batch = Assert.Throws(() => Batch(RedisCommand.BITFIELD, default(BitFieldOperation)));
+ Assert.Equal("operations", batch.ParamName);
+
+ var single = Assert.Throws(() => Single(RedisCommand.BITFIELD, default(BitFieldOperation)));
+ Assert.Equal("operation", single.ParamName);
+ }
+
+ [Fact]
+ public void MutatingTheOperationsAfterIssue_FailsBeforeAnythingIsWritten()
+ {
+ // the batch form aliases the caller's memory rather than copying it, and the shape depends on
+ // the contents, so a mutation has to be caught before the header goes out
+ var operations = new[] { BitFieldOperation.Set(BitFieldEncoding.UInt8, 0, 1) };
+ var msg = new RedisDatabase.BitFieldMessage(0, CommandFlags.None, RedisCommand.BITFIELD, (RedisKey)"k", operations);
+
+ operations[0] = default;
+
+ using var conn = new TestConnection(startReading: false);
+ var box = TaskResultBox.Create(out _, null);
+ msg.SetSource(box, ResultProcessor.NullableInt64);
+
+ Assert.Throws(() => conn.WriteOutbound(msg));
+ Assert.Empty(conn.GetOutboundData().ToArray());
+ }
+
+ [Theory]
+ // all-GET: no side effects to replay, whichever command we end up issuing
+ [InlineData(true, false, true, "BITFIELD_RO", CommandFlags.CommandRetryReadOnly)]
+ [InlineData(true, false, false, "BITFIELD", CommandFlags.CommandRetryReadOnly)]
+ // SET only: a replay lands on the same value, because the offset is positional
+ [InlineData(false, false, false, "BITFIELD", CommandFlags.CommandRetryWriteLastWins)]
+ // anything with an INCRBY compounds, so it keeps BITFIELD's accumulating default
+ [InlineData(false, true, false, "BITFIELD", CommandFlags.None)]
+ public void CommandAndRetryCategoryFollowThePayload(bool allGet, bool anyIncrement, bool readOnlyAvailable, string expectedCommand, CommandFlags expectedCategory)
+ {
+ var flags = CommandFlags.None;
+ Assert.Equal(expectedCommand, RedisDatabase.SelectBitFieldCommand(allGet, anyIncrement, readOnlyAvailable, ref flags).ToString());
+
+ // CommandFlags.None here means "no opinion", leaving BITFIELD's own accumulating default in place
+ Assert.Equal(expectedCategory, flags & Message.MaskRetryCategory);
+ }
+
+ [Fact]
+ public void AnExplicitRetryCategoryIsNotOverridden()
+ {
+ var flags = CommandFlags.CommandRetryNever;
+ Assert.Equal(RedisCommand.BITFIELD_RO, RedisDatabase.SelectBitFieldCommand(allGet: true, anyIncrement: false, readOnlyAvailable: true, ref flags));
+ Assert.Equal(CommandFlags.CommandRetryNever, flags & Message.MaskRetryCategory);
+ }
+
+ [Fact]
+ public void ReadOnlyVariantIsNotPrimaryOnly()
+ {
+ // BITFIELD is a write server-side even when every sub-operation is a GET; BITFIELD_RO is not
+ Assert.True(RedisCommand.BITFIELD.IsPrimaryOnly());
+ Assert.False(RedisCommand.BITFIELD_RO.IsPrimaryOnly());
+ }
+}
diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BitPositionRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BitPositionRoundTrip.cs
new file mode 100644
index 000000000..d083bbb68
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/BitPositionRoundTrip.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace StackExchange.Redis.Tests.RoundTripUnitTests;
+
+public class BitPositionRoundTrip(ITestOutputHelper log)
+{
+ [Fact(Timeout = 1000)]
+ public async Task ExplicitEnd_IsSent()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetStringBitPositionMessage("k", false, 0, -1, StringIndexType.Byte, CommandFlags.None);
+ const string RequestResp = "*5\r\n$6\r\nBITPOS\r\n$1\r\nk\r\n$1\r\n0\r\n$1\r\n0\r\n$2\r\n-1\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64, RequestResp, ":-1\r\n", log: log);
+ Assert.Equal(-1, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task ExplicitEnd_WithBitIndexType_SendsToken()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetStringBitPositionMessage("k", false, 0, 7, StringIndexType.Bit, CommandFlags.None);
+ const string RequestResp = "*6\r\n$6\r\nBITPOS\r\n$1\r\nk\r\n$1\r\n0\r\n$1\r\n0\r\n$1\r\n7\r\n$3\r\nBIT\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64, RequestResp, ":-1\r\n", log: log);
+ Assert.Equal(-1, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task UnboundedEnd_OmitsEnd()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetStringBitPositionMessage("k", false, 0, StringIndex.Unbounded, StringIndexType.Byte, CommandFlags.None);
+ const string RequestResp = "*4\r\n$6\r\nBITPOS\r\n$1\r\nk\r\n$1\r\n0\r\n$1\r\n0\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64, RequestResp, ":8\r\n", log: log);
+ Assert.Equal(8, result);
+ }
+
+ [Fact(Timeout = 1000)]
+ public async Task UnboundedEnd_KeepsStart()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var msg = db.GetStringBitPositionMessage("k", true, 2, StringIndex.Unbounded, StringIndexType.Byte, CommandFlags.None);
+ const string RequestResp = "*4\r\n$6\r\nBITPOS\r\n$1\r\nk\r\n$1\r\n1\r\n$1\r\n2\r\n";
+
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.Int64, RequestResp, ":16\r\n", log: log);
+ Assert.Equal(16, result);
+ }
+
+ [Fact]
+ public void UnboundedEnd_RejectsBitIndexType()
+ {
+ var db = new RedisDatabase(null!, 0, null);
+ var ex = Assert.Throws(() => db.GetStringBitPositionMessage("k", false, 0, StringIndex.Unbounded, StringIndexType.Bit, CommandFlags.None));
+ Assert.Equal("indexType", ex.ParamName);
+ }
+}