Skip to content

add ReadOnlyMemory<RedisKeyOrValue> and Lease<byte> for Lua and Execute - #3201

Merged
mgravell merged 20 commits into
StackExchange:mainfrom
pairbit:lua-memory-pr
Sep 9, 2026
Merged

add ReadOnlyMemory<RedisKeyOrValue> and Lease<byte> for Lua and Execute#3201
mgravell merged 20 commits into
StackExchange:mainfrom
pairbit:lua-memory-pr

Conversation

@pairbit

@pairbit pairbit commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

(outdated)

Tasks:

  • add ReadOnlyMemory<RedisKeyOrValue> API for Lua
  • add Lease<byte>? API for Lua
  • add ScriptEvaluateReadOnlyAsync again if the script does not exist.
  • add rent args for key-prefix path
  • add ReadOnlyMemory<RedisKeyOrValue> API for Execute
  • add Lease<byte>? API for Execute
  • add IScriptRequestDisposer to Execute (to return memory to pools, including for fire-and-forget mode)

Links
#2346
#2843
#2844

Please consider this PR, this issue has been raised for a long time

* add RedisKeyOrValue

* add operators and override to RedisKeyOrValue

* add ThrowInvalidCast

* add IScriptRequestDisposer

* ScriptEvaluateMemory

* ScriptEvaluateMemoryReadOnlyAsync

* ScriptEvalMemoryMessage

* fix bug ScriptUnavailable ScriptEvaluateReadOnlyAsync

* add Lease ScriptEvaluateMemory

* add Prefixed

* FromKey FromValue

* undo Disposer
@pairbit pairbit changed the title add ReadOnlyMemory<RedisKeyOrValue> and Lease<byte> for Lua add ReadOnlyMemory<RedisKeyOrValue> and Lease<byte> for Lua and Execute Aug 31, 2026
@mgravell

Copy link
Copy Markdown
Collaborator

There's a couple of reasons I've been deferring on this for a little bit...

If we're thinking "efficiency", there's the problem of the return value, and managing it in a way that is flexible and efficient.

I'm not quite ready to release it on the world yet, but a large part of the IO rewrite is "yet to come", and explicitly targets flexible read / write custom command scenarios, including Lua (but also any ad-hoc commands). I'm torn between compromising on an eval[sha] now, bs giving you the real thing "soon" - trust me, it'll be much easier than this.

Thoughts?

@pairbit

pairbit commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

I understand that you want to give the maximum. But the perfect is the enemy of the good. The solution I propose will completely satisfy many and will be a compromise. For most tasks, the current solution will be sufficient. But I understand it's not perfect.

I'd really appreciate your consideration of my merge request. It's a good temporary compromise.

@pairbit

pairbit commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

I'm currently in dire need of the ScriptEvaluateLease and ExecuteLease methods.
In my projects I use only lua scripts and direct call commands, and I really miss the rent on return.

If you don't like the idea of ​​introducing a new RedisKeyOrValue or ScriptRequestDisposer type, I'm willing to live with their absence.

I am willing to listen to your terms on which you would agree to add methods with a Lease return.

I apologize for being stubborn, I have been waiting for these changes for a very long time.

@mgravell

mgravell commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

I'll try to look today. Balancing competing needs is always tricky. I'm not against reasonable APIs. The return value is the trickiest bit, since this can technically be an arbitrary tree of different data types.

Thinking outside of the box: how might a lease + RespReader work for you? I.e. the lease isn't a value, but an API over the tree? If you're accessing a scalar or BLOB: this is then one line to get the blob, and I could provide helper APIs for those.

@pairbit

pairbit commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

I understand that the Lease<byte> object only works with a scalar result, which is fine for me since I'm working with blob. I try to avoid trees and arrays as a result.

Thinking outside of the box: how might a lease + RespReader work for you?

Returning lease + RespReader would be a great solution!

After the review, please give me your opinion on my idea with IScriptRequestDisposer, which returns all data associated with a request to pools after writing. IScriptRequestDisposer could not be used for ScriptEvaluate because the message can be used twice.

@mgravell

mgravell commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

OK; I can see that I have "push" access; if it is OK with you, I'm going to take one half of this (the input API, i.e. RedisKeyOrValue etc), but totally change the response half, pushing to your branch. I understand that efficiently accessing BLOB payloads is a key goal, so I will prioritize that aspect. Let me know if this plan concerns you.

@mgravell

mgravell commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Making good progress locally; just a heads-up: I need to remove the IRequestDisposer scenario - that doesn't really work safely as-written (because of -MOVED etc), and it will actually become redundant soon. For now, the caller must own the lifetime via await; it is acknowledged that this today won't work well with fire-and-forget (bit of a footgun there, which we'll document), but when the IO rewrite work is complete, the serialize moves way further out, and that problem goes away.

…espResult

Ivan's PR introduced the core idea: typed key/value args for EVAL and
ad-hoc commands, plus a low-allocation way to read the reply. This
reworks the implementation end-to-end while keeping that idea intact:

- New RespResult type: a leased, undecoded view over a raw RESP reply
  (IDisposable, backed by a pooled buffer), replacing the original
  Lease-returning design. Three null singletons preserve which of the
  RESP2/RESP3 null encodings was actually on the wire.

- Public API settled on full words, no abbreviations: ExecuteResp(Async),
  ScriptEvaluateResp(Async), ScriptEvaluateReadOnlyResp(Async).
  ScriptEvaluateResp takes separate keys/values (ReadOnlyMemory<RedisKey>,
  ReadOnlyMemory<RedisValue>) since Lua's KEYS/ARGV never interleave;
  ExecuteResp keeps ReadOnlyMemory<RedisKeyOrValue> since an arbitrary
  command can place a key anywhere in its argument list.

- RedisKeyOrValue rewritten from an unsafe StructLayout/Unsafe.As union
  to a safe RedisValue + byte[]? _keyPrefix design, so it actually
  supports KeyPrefixed key-prefix rewriting (the original layout did
  not). Construction funnels through FromKey/FromValue and implicit
  operators only, to avoid ambiguity with bare literals.

- IRequestDisposer removed: it fired on every WriteImpl, which re-runs
  on retry/redirect, making it unsound. RespResult's own IDisposable
  supersedes it.

- KeyPrefixedDatabase/KeyPrefixed: both wrappers now do a
  fire-and-forget-aware copy/lease split for all three Resp methods
  (not just Execute), and return the pooled buffer to ArrayPool on
  success *or* RedisServerException - both mean the server fully
  received and processed the write, so a retry can no longer be using
  the same buffer. Zero-key/all-value calls (e.g. RediSearch-style
  ad-hoc commands) pass through with no allocation at all.

- [AutoDatabase]: added the two IRedisArgsMutator.Map overloads
  (ReadOnlyMemory<RedisKey>, ReadOnlyMemory<RedisKeyOrValue>) that were
  missing, so key-prefixing via [AutoDatabase] is actually possible for
  these methods once something needs it (verified against a temporarily
  mutator-flipped RetryDatabase, then reverted).

- New RespReader.TryGetRawSpan/CopyRawTo (RESPite) to capture a raw
  frame before decoding, which RespResult's capture path depends on.

- RespReader/RespPrefix/RespException/RespAttributeReader<T> (RESPite)
  and RespResult/RespReaderExtensions (StackExchange.Redis) are no
  longer behind the SER004 experimental diagnostic - this is the
  intended, stable read path for the new API. The wire-level IO
  internals underneath (buffer pooling, frame scanning) remain
  experimental.

- Docs: new Execute.md, rewritten Scripting.md - basic use, reading
  results, leasing the argument buffer on a hot path, walking tree
  replies via AggregateChildren/ReadPastArray.

- Tests: unit tests against raw RESP strings, live-server integration
  tests (RunPerProtocol), NSubstitute mock tests for KeyPrefixedDatabase,
  and coverage for non-scalar replies (AggregateChildren, ReadPastArray,
  nested sub-arrays, a real array-returning ExecuteResp call).

Measured: reading a scalar/blob reply via ExecuteResp/ScriptEvaluateResp
+ ReadLease()/CopyTo() instead of Execute/ScriptEvaluate + (byte[])result
cuts client-side allocation per call by roughly 50-95%, scaling with the
size of the blob.

Co-authored-by: Ivan Tikhonov <pairbit@gmail.com>
@mgravell

mgravell commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Hi Ivan - thanks for this, the underlying idea (typed key/value args, a low-allocation reply path for EVAL and ad-hoc commands) is exactly right and is the reason I wanted to get this in.

I've pushed a single commit to this branch that reworks the implementation pretty much top to bottom, so I want to be upfront about the scope before you look: the request-side shape survives (RedisKeyOrValue, keys-and-values-through-EVAL), but the response side, the RedisKeyOrValue internals, the public API names, and the
KeyPrefixedDatabase wiring are all different from what you had.

Highlights:

  • The lease-returning response type became RespResult
    (IDisposable, raw/undecoded, pooled buffer) with ExecuteResp/
    ScriptEvaluateResp/ScriptEvaluateReadOnlyResp (+ Async) as the
    final names.
  • RedisKeyOrValue's internal layout changed from an unsafe union to
    a plain RedisValue + prefix-byte-array pair, mainly so it can
    actually participate in KeyPrefixed key-prefixing (the original
    layout couldn't).
  • IRequestDisposer is gone - it wasn't safe to fire per-WriteImpl
    given retries/redirects; RespResult.Dispose() covers what it was
    for.
  • Docs (Execute.md, Scripting.md) and a fuller test pass (unit +
    live-server + mock tests, including array/tree replies) came along
    with it.
  • The PR now includes the work to un-gate the RespReader API

Full details are in the commit message. Would genuinely like your take - on the API shape, anything I've missed from the original design intent, or anywhere you think this went the wrong way.

The tests deliberately include examples related to the "lease" scenario - both using ReadLease(), and (separately) copying the raw payload out from the RespReader:

     [Fact]
    public async Task ScriptEvaluateResp_ScalarBlob_ReadLease()
    {
        await using var conn = Create();
        var db = conn.GetDatabase();

        using var result = db.ScriptEvaluateResp("return 'hello world'", default, default);
        Assert.False(result.IsNull);

        using var lease = result.ReadScalar().ReadLease();
        Assert.Equal("hello world", Encoding.UTF8.GetString(lease!.Span));
        // ^^^ you wouldn't do this in real code - there is a reader.ReadString() method!
    }

    [Fact]
    public async Task ScriptEvaluateResp_ScalarBlob_CopyToCallerBuffer()
    {
        await using var conn = Create();
        var db = conn.GetDatabase();

        using var result = db.ScriptEvaluateResp("return 'hello world'", default, default);
        var reader = result.ReadScalar();
        byte[] buffer = new byte[reader.ScalarLength()]; // in reality: your own lease
        var copied = reader.CopyTo(buffer);
        Assert.Equal("hello world", Encoding.UTF8.GetString(buffer, 0, copied));
        // ^^^ you wouldn't do this in real code - there is a reader.ReadString() method!
    }

# Conflicts:
#	src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
@pairbit

pairbit commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Excellent solution. I couldn't have asked for anything better.

I have a question.
In my code I will use the following approach with renting:

var values = ArrayPool<RedisValue>.Shared.Rent(1);
values[0] = "myvalue";
var canReturn = true;
try
{
    using RespResult result = db.ScriptEvaluateResp(script, values: values.AsMemory(0, 1));
    // use result...
}
catch (RedisServerException)
{
    throw;
}
catch // I don't like it
{
    canReturn = false; 
    throw;
}
finally
{
    if (canReturn) ArrayPool<RedisValue>.Shared.Return(values, clearArray: true);
}

I don't like catch without specifics.
An ArgumentException or InvalidOperationException may be thrown.
Is there a more precise way to determine whether a message will be reused?

@pairbit

pairbit commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

I don't like that i have to rent the buffer twice to get a BulkString.
The first time, we lease the entire RespResult response.

using var respResult = await db.ScriptEvaluateRespAsync(script, keys, values)

The second time we rent to get the value.

if (result.Prefix == RESPite.Messages.RespPrefix.BulkString)
{
    using var lease = result.ReadScalar().ReadLease();
}

For large blobs this may not be effective.
I propose to inherit the RespResult class from IMemoryOwner<byte> and add the AsBulkString method, which will return the memory chunk cut off from RespResult.
For example:

Task<IMemoryOwner<byte>> RunScript()
{
	using RespResult result = db.ScriptEvaluateResp(script, keys, values);
	return result.AsBulkString(); //or AsScalar();
}

What do you say?

@mgravell

mgravell commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

I say that's technically impossible. A strict interpretation of RESP3 means that a payload body does not need to be a single contiguous chunk - it can be streamed. This means at a minimum we'd need to consider ReadOnlySequence-byte. Unfortunately, ReadOnlySequence-byte doesn't have a well-understood lifetime metaphor.

However, in reality it almost certainly will be contiguous, and there are APIs to fetch that directly - for example, I believe TryGetSpan and various inline parse methods are zero copy.

It is also important to note that we need to avoid ambiguity over payload lifetime, which is a huge issue.

@mgravell

mgravell commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Notes to self:

  • defensive input copy on F+F
  • check for functions support

@pairbit

pairbit commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

I was thinking about ROS. But the current RespResult implementation only uses contiguous memory blocks. I understand there's a TryGetSpan() method, but I don't want to externalize the RespResult class. Perhaps I could add TryGetScalar() that returns IMemoryOwner?

@mgravell

mgravell commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

I do think I can do something here with ref-counted memory, optimized for the linear case. I'll look. The calling code will need to dispose each, but it'll avoid a copy/lease unless it is genuinely discontiguous.

@mgravell

mgravell commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

I was thinking about...

I think we're near the same page; I can make something work - I have an idea. Technically it won't be a strict guarantee to not do an extra copy, but in reality it always will (not do an extra copy). So: yes, I can improve the current PR state that always does an extra copy.

@mgravell

mgravell commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

if (result.Prefix == RESPite.Messages.RespPrefix.BulkString)

Perfect reminder for me: I need to make the docs lean people towards IsScalar / IsAggregate - focusing on specific prefixes tends to not play well with RESP2 vs RESP3 (although you'll get away with it in your case).

@mgravell

mgravell commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

I don't like catch without specifics.

I don't disagree. When I finish the write half of the IO core rewrite, the answer will be "always". But I'll have a bit more of a think.

@mgravell

mgravell commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

But the current RespResult implementation only uses contiguous memory blocks.

You misunderstand me. The payload itself can be non-contiguous inside a contiguous buffer - split over multiple fragments. At least, theoretically. I don't think any real server actually ever returns streamed payloads, though!

@pairbit

pairbit commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

You misunderstand me. The payload itself can be non-contiguous inside a contiguous buffer - split over multiple fragments. At least, theoretically. I don't think any real server actually ever returns streamed payloads, though!

Thanks for the clarification. I didn't know about this possibility.

RespResult already copies the raw frame out of the connection's read buffer;
ReadLease then copied the payload out of *that*, so pulling a blob out of
ScriptEvaluateResp/ExecuteResp rented and filled two buffers for one value.

Make the reply buffer reference-counted and hand out the second one by
reference. RefCountedBuffer is a MemoryManager<byte> rather than a plain
IMemoryOwner<byte> for two reasons: every Memory/Span access routes back
through GetSpan, so use after the buffer has gone back to the pool throws
rather than quietly reading somebody else's data; and MemoryManager<T>
implements IDisposable explicitly, so the single reachable Dispose can only
mean "release one reference" - there is no second disposal concept to confuse
it with, and no guard flag needed.

The reader finds the buffer through one new field: a single service slot that
either is the service or is an IServiceProvider, so services the reader does
not know about in advance can still be reached without a field each.

Lease<T> is otherwise untouched - it gains an offset, and its existing Dispose
already does the right thing, because ((IMemoryOwner<T>)buffer).Dispose()
lands on the manager's explicit Dispose, i.e. a release. Overriding
MemoryManager<T>.TryGetArray keeps ArraySegment - and so DecodeString and
AsStream - working on a shared lease.

The same slot also carries the buffer pool, which lets ReadLease drop its pool
argument entirely: on the sharing path any such argument was going to be
silently ignored, since the lease takes whatever buffer the reply already sits
in. Connection-path readers get a services object allocated once per
multiplexer and cached per connection; it reads through to the configuration
rather than capturing the pool, so this stays a pure indirection. AsLease and
its walk from connection to config to pool go away with it.

That does turn an explicit argument into an implicit lookup, so the pool is
now covered by a test that asserts a configured ResponseBufferPool really is
rented from - there was no such test before, and losing the wiring would
otherwise degrade silently to ArrayPool<byte>.Shared.

Sharing is contextual, and the docs say so: it happens when the reader's
source can offer a counted reservation, which today means RespResult. The
Lease<byte>-returning commands, and any reader built over a caller's own
bytes, still copy. Either way the lease is owned by the caller and disposed
the same way, so calling code does not have to know which it got - except
that in the sharing case the reply stays rented until the lease goes, so a
short value taken from a large reply keeps the whole reply alive.

Everything new here is internal; the only public API change is the removal of
ReadLease's pool argument, which is unshipped.
@pairbit

pairbit commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

I've already looked at your commit. It's amazing.

Remember when I mentioned the place where you lianize ROS?
Now you can pass that buffer directly to RespReader.
It may be a rare scenario, but it seems like it would be amazing, don't you think?

private void OnResponseFrame(RespPrefix prefix, ReadOnlySequence<byte> payload)
{
    if (payload.IsSingleSegment)
    {
        OnResponseFrame(prefix, payload.FirstSpan, ref SharedNoLease);
    }
    else
    {
        var len = checked((int)payload.Length);
        var memoryPool = BridgeCouldBeNull?.Multiplexer.RawConfig.ResponseBufferPool ?? MemoryPool<byte>.Shared;
        var memoryOwner = memoryPool.Rent(len);
        Span<byte> oversized = memoryOwner.Memory.Span.Slice(0, len);

        payload.CopyTo(oversized);
		
        // set buffer in RespReader
        OnResponseFrame(prefix, oversized, ref memoryOwner);

        memoryOwner?.Dispose();
    }
}

@pairbit

pairbit commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Are these final changes? When is the merger planned?

@mgravell

mgravell commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Sorry, long weekend, looking again now!

Now you can pass that buffer directly to RespReader.

That's probably a step too far, or at least one to defer until we have the full end-to-end IO v3 core working - it would mix two buffer pools with delicate handling. For now I'd rather focus on reliability.

Are these final changes? When is the merger planned?

I need to think a little bit more about the inbound memory lifetime - that's my job for today.

# Conflicts:
#	src/StackExchange.Redis/PhysicalConnection.cs
…lly fire

ScriptEvaluateResp, ScriptEvaluateReadOnlyResp and their async twins all catch
RedisServerException "when (msg.IsScriptUnavailable)" and re-issue, which is how
an EVALSHA whose script the server has forgotten becomes an EVAL. But nothing
ever set that flag on this path: noticing NOSCRIPT - flushing our cached hashes
and marking the message - lived only in ScriptResultProcessor, the processor for
the classic RedisResult APIs. RespResultProcessor did not do it, so the filter
could never match and all four retries were unreachable; the NOSCRIPT surfaced
to the caller instead.

Hoist that handling to NoteIfScriptUnavailable on the base processor and call it
from both. Any processor that can be the target of an EVALSHA needs it.

This showed up as an intermittent failure of
RespResultTests.ScriptEvaluateReadOnlyResp_Works: ScriptingTests calls
SCRIPT FLUSH in about ten places and xUnit runs classes in parallel, so it wipes
the server's cache while another class still holds the hash.

The test provokes it deliberately, but by poisoning only its own connection's
client-side cache rather than by calling SCRIPT FLUSH - a server-wide flush from
a test races every other test using scripts, which is what made this intermittent
in the first place.
…spPrefix

The category is stable across protocols; the specific prefix is not, and which
protocol you get depends on the server and configuration rather than on your
code. Aggregates are the sharp edge: RESP2 has exactly one aggregate encoding,
so everything arrives as Array, while RESP3 splits it into map, set and push.
Prefix == RespPrefix.Array therefore works perfectly on RESP2 and silently stops
matching on RESP3.

Both docs previously pointed the other way - Execute.md listed "IsNull/Prefix" as
the rundown, and Scripting.md sent readers to Prefix for a test that IsNull
answers.

The accompanying test pins the table the docs now assert. Written after an
earlier draft claimed SISMEMBER returns a boolean under RESP3, which it does not;
a doc making protocol claims with nothing enforcing them goes stale quietly.
…possible

ScriptEvaluateReadOnly and ScriptEvaluateReadOnlyResp took the _RO command as
far as the Message and no further: both ScriptEvaluateMessage and
ScriptEvalMessage hardcoded EVAL/EVALSHA in WriteImpl, so the read-only APIs
were read-only in name only - confirmed with MONITOR.

Send the real thing where the server is 7.0+ and the command map allows it, and
otherwise fall back to EVAL/EVALSHA, which is what these APIs have always sent.
The two halves of that condition are resolved where each is knowable: the map at
construction (it also decides what CheckMessage will accept), the server version
at write time, when we know which server we are actually talking to and can
re-resolve after a redirect. Both halves require EVAL_RO *and* EVALSHA_RO,
because hash-versus-script is only decided at write time and can differ between
an attempt and its retry.

Falling back pins the retry category to CommandRetryReadOnly first. EVAL_RO
defaults to that while EVAL defaults to CommandRetryWriteAccumulating, so
swapping the command alone would quietly make a script the caller asked for
read-only retry like a write. An explicit category from the caller still wins.

Also fixes ServerEndPoint.GetScriptHash, which recognised a caller-supplied SHA1
only for EVALSHA - so passing a hash to the read-only API skipped the cache and
tried to EVAL the hash text as a script.
The retry category a fallback ends up with is message state - it never reaches
the wire and changes no reply - so it was asserted via an internal
GetReadOnlyScriptMessageForTests that built a message without sending it. That
is a test-only method on a shipping type, and it constructed a message shape no
real call site produces.

ForReadOnlyScript only ever wanted the command map, so make it a static taking
one; the test then calls it directly and asserts the command and the flags it
sets, with no seam and no synthetic message. It also lands closer to the logic:
the previous test could only observe the outcome through a message.

Testing the helper alone would stop short of the reason the pin matters - that
Message's own defaulting leaves an already-chosen category alone - so that link
is now asserted explicitly rather than implied.

Also reunites ForReadOnlyScript with its doc comment, which the seam had been
sitting in front of.
@mgravell
mgravell merged commit 7648fc8 into StackExchange:main Sep 9, 2026
4 checks passed
@mgravell mgravell mentioned this pull request Sep 10, 2026
mgravell added a commit that referenced this pull request Sep 10, 2026
Three consumer-visible changes in this cycle that a build bump would under-signal:

- net6.0 dropped from TargetFrameworks (#3208). Anyone still targeting it does
  not get a worse version, they get no version.
- System.IO.Hashing's declared floor moved from 10.0.5 to 10.0.12 (#3210), on
  every target framework.
- ScriptEvaluateReadOnly now genuinely sends EVAL_RO where the server and command
  map allow it, so a script that *writes* on that API starts being rejected on
  7.0+ where plain EVAL used to let it through.

Plus the new surface from #3201 - RespResult, RedisKeyOrValue, the *Resp family,
and the un-gated RespReader API - which is additive but substantial.

assemblyVersion stays at 3.0 deliberately: it is decoupled from the package
version precisely so that a minor bump is not a binding break, and moving it
would be one for anyone with a compiled reference or a binding redirect.
Confirmed against a local pack - package 3.2.0, assembly still 3.0.0.0.

versionHeightOffset went with it: it was scoped to 3.1 via
versionHeightOffsetAppliesTo, so it stopped applying the moment the version
changed. Verified the computed version is identical with and without it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants