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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/sdk-compat-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,14 @@ jobs:
echo "## SDK Version: $VERSION" >> $GITHUB_STEP_SUMMARY

- name: Override Cosmos SDK version
shell: bash
run: |
dotnet add src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj \
package Microsoft.Azure.Cosmos \
--version ${{ steps.sdk-version.outputs.version }}
VERSION="${{ steps.sdk-version.outputs.version }}"
# Update CosmosSDKVersion in the root Directory.Build.props — this single property
# drives the PackageReference, the HybridRow hint path, AND the
# COSMOS_SDK_PREVIEW_METHODS compile-time symbol across all projects in the repo.
sed -i "s|<CosmosSDKVersion>[^<]*</CosmosSDKVersion>|<CosmosSDKVersion>${VERSION}</CosmosSDKVersion>|" \
Directory.Build.props

- name: Build
run: dotnet build --configuration Release
Expand Down
21 changes: 21 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,25 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

<PropertyGroup>
<!-- Single source of truth for the Cosmos SDK version used across all projects.
The CI compat-check workflow overrides this value to test against different
SDK releases without changing any other file. -->
<CosmosSDKVersion>3.58.0</CosmosSDKVersion>
</PropertyGroup>

<PropertyGroup>
<!-- Strip any pre-release suffix (e.g. "-preview.0") before version comparisons. -->
<_CosmosVersionBase>$([System.Text.RegularExpressions.Regex]::Replace($(CosmosSDKVersion), '-.*$', ''))</_CosmosVersionBase>
</PropertyGroup>

<PropertyGroup Condition="$([MSBuild]::VersionGreaterThanOrEquals('$(_CosmosVersionBase)', '3.59.0'))">
<!-- Container gained GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes,
GetPartitionKeyRangesAsync, and SemanticRerankAsync as public abstract members
starting from 3.59.0-preview.0. Enable their implementations (and matching tests)
only when building against that version or later so the code still compiles with
3.58.x (where those methods are internal/virtual and inaccessible from outside). -->
<DefineConstants>$(DefineConstants);COSMOS_SDK_PREVIEW_METHODS</DefineConstants>
</PropertyGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,9 @@
<InternalsVisibleTo Include="CosmosDB.InMemoryEmulator.Tests.Performance" />
</ItemGroup>

<PropertyGroup>
<!-- Resolve the Cosmos SDK version for the HybridRow DLL reference path.
This avoids hardcoding the version so updates to the PackageReference don't break the build. -->
<CosmosSDKVersion>3.58.0</CosmosSDKVersion>
</PropertyGroup>

<ItemGroup>
<!-- CosmosSDKVersion is defined in the root Directory.Build.props and updated by the
CI compat-check workflow when testing against a different SDK release. -->
<PackageReference Include="Microsoft.Azure.Cosmos" Version="$(CosmosSDKVersion)" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
Expand Down
69 changes: 69 additions & 0 deletions src/CosmosDB.InMemoryEmulator/InMemoryChangeFeedProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,75 @@ private async Task PollAsync(CancellationToken cancellationToken)
}
}

#if COSMOS_SDK_PREVIEW_METHODS
/// <summary>
/// A <see cref="ChangeFeedProcessor"/> implementation that delivers <b>all versions and deletes</b>
/// of every change (including intermediate updates) as <see cref="ChangeFeedItem{T}"/> objects,
/// matching the behaviour of <c>GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes</c>.
/// </summary>
internal sealed class InMemoryAllVersionsChangeFeedProcessor<T> : ChangeFeedProcessor
{
private readonly InMemoryContainer _container;
private readonly Container.ChangeFeedHandler<ChangeFeedItem<T>> _handler;
private CancellationTokenSource _cts;
private Task _pollingTask;
private long _checkpoint;

internal InMemoryAllVersionsChangeFeedProcessor(
InMemoryContainer container,
Container.ChangeFeedHandler<ChangeFeedItem<T>> handler)
{
_container = container;
_handler = handler;
}

public override Task StartAsync()
{
_checkpoint = _container.GetChangeFeedCheckpoint();
_cts = new CancellationTokenSource();
_pollingTask = PollAsync(_cts.Token);
return Task.CompletedTask;
}

public override async Task StopAsync()
{
if (_cts != null)
{
await _cts.CancelAsync();
try { await _pollingTask; }
catch (OperationCanceledException) { }
_cts.Dispose();
_cts = null;
}
}

private async Task PollAsync(CancellationToken cancellationToken)
{
var context = new InMemoryChangeFeedProcessorContext();

while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);

var feedItems = _container.GetAllVersionsChangeFeedItems<T>(_checkpoint);

if (feedItems.Count > 0)
{
try
{
await _handler(context, feedItems, cancellationToken);
_checkpoint += feedItems.Count;
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
// Handler threw — do not advance checkpoint so the same batch is redelivered
}
}
}
}
}
#endif

internal static class ChangeFeedProcessorBuilderFactory
{
private static readonly Assembly CosmosAssembly = typeof(Container).Assembly;
Expand Down
119 changes: 119 additions & 0 deletions src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Globalization;
using System.Net;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Azure.Cosmos;
Expand Down Expand Up @@ -1967,6 +1968,73 @@ public FeedIterator<T> GetChangeFeedIterator<T>(long checkpoint)
return new InMemoryFeedIterator<T>(items);
}

#if COSMOS_SDK_PREVIEW_METHODS
/// <summary>
/// Returns all change feed entries starting from <paramref name="fromCheckpoint"/> as
/// <see cref="ChangeFeedItem{T}"/> objects, preserving every version and delete.
/// Used by <see cref="InMemoryAllVersionsChangeFeedProcessor{T}"/>.
/// </summary>
internal IReadOnlyList<ChangeFeedItem<T>> GetAllVersionsChangeFeedItems<T>(long fromCheckpoint)
{
lock (_changeFeedLock)
{
var start = (int)Math.Max(0, fromCheckpoint);
var result = new List<ChangeFeedItem<T>>(_changeFeed.Count - start);

for (var i = start; i < _changeFeed.Count; i++)
{
var entry = _changeFeed[i];
var opType = DetermineChangeFeedOperationType(i);
var metadata = CreateChangeFeedMetadata(opType, i, entry.Id);
var feedItem = new ChangeFeedItem<T> { Metadata = metadata };
if (!entry.IsDelete)
feedItem.Current = JsonConvert.DeserializeObject<T>(entry.Json, JsonSettings);
result.Add(feedItem);
}

return result;
}
}

private static readonly Type ChangeFeedMetadataType = typeof(ChangeFeedMetadata);
private static readonly PropertyInfo CfmOperationTypeProp = ChangeFeedMetadataType.GetProperty("OperationType");
private static readonly PropertyInfo CfmLsnProp = ChangeFeedMetadataType.GetProperty("Lsn");
private static readonly PropertyInfo CfmIdProp = ChangeFeedMetadataType.GetProperty("Id");

private static ChangeFeedMetadata CreateChangeFeedMetadata(
ChangeFeedOperationType operationType, long lsn, string id)
{
var metadata = (ChangeFeedMetadata)RuntimeHelpers.GetUninitializedObject(ChangeFeedMetadataType);
CfmOperationTypeProp?.SetMethod?.Invoke(metadata, new object[] { operationType });
CfmLsnProp?.SetMethod?.Invoke(metadata, new object[] { lsn });
CfmIdProp?.SetMethod?.Invoke(metadata, new object[] { id });
return metadata;
}

/// <summary>
/// Determines whether the change feed entry at position <paramref name="index"/> represents
/// a Create, Replace, or Delete by scanning prior entries for the same item.
/// Must be called while <see cref="_changeFeedLock"/> is held.
/// </summary>
private ChangeFeedOperationType DetermineChangeFeedOperationType(int index)
{
var entry = _changeFeed[index];
if (entry.IsDelete)
return ChangeFeedOperationType.Delete;

// Scan backwards to find the most recent prior entry for this (Id, PartitionKey)
for (var j = index - 1; j >= 0; j--)
{
var prev = _changeFeed[j];
if (prev.Id == entry.Id && prev.PartitionKey == entry.PartitionKey)
return prev.IsDelete ? ChangeFeedOperationType.Create : ChangeFeedOperationType.Replace;
}

// No prior entry → this is the first occurrence (Create)
return ChangeFeedOperationType.Create;
}
#endif

public override FeedIterator GetChangeFeedStreamIterator(
ChangeFeedStartFrom changeFeedStartFrom, ChangeFeedMode changeFeedMode,
ChangeFeedRequestOptions changeFeedRequestOptions = null)
Expand Down Expand Up @@ -2229,6 +2297,13 @@ public override ChangeFeedProcessorBuilder GetChangeFeedEstimatorBuilder(
TimeSpan? estimationPeriod = null)
=> ChangeFeedProcessorBuilderFactory.Create(processorName, new NoOpChangeFeedProcessor());

#if COSMOS_SDK_PREVIEW_METHODS
public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes<T>(
string processorName, Container.ChangeFeedHandler<ChangeFeedItem<T>> onChangesDelegate)
=> ChangeFeedProcessorBuilderFactory.Create(processorName,
new InMemoryAllVersionsChangeFeedProcessor<T>(this, onChangesDelegate));
#endif

// ═══════════════════════════════════════════════════════════════════════════
// Feed Ranges
// ═══════════════════════════════════════════════════════════════════════════
Expand All @@ -2249,6 +2324,50 @@ public override Task<IReadOnlyList<FeedRange>> GetFeedRangesAsync(CancellationTo
return Task.FromResult<IReadOnlyList<FeedRange>>(ranges);
}

#if COSMOS_SDK_PREVIEW_METHODS
public override Task<IEnumerable<string>> GetPartitionKeyRangesAsync(
FeedRange feedRange, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();

var count = Math.Max(1, FeedRangeCount);
var step = 0x1_0000_0000L / count;
var (queryMin, queryMax) = ParseFeedRangeBoundaries(feedRange);

var rangeIds = new List<string>();
for (var i = 0; i < count; i++)
{
if (queryMin == null || queryMax == null)
{
rangeIds.Add(i.ToString());
continue;
}

var rangeMin = (uint)(i * step);
var rangeMax = i == count - 1 ? uint.MaxValue : (uint)((i + 1) * step) - 1u;

// Overlap check: intervals [rangeMin, rangeMax] and [queryMin, queryMax]
if (rangeMin <= queryMax.Value && queryMin.Value <= rangeMax)
rangeIds.Add(i.ToString());
}

return Task.FromResult<IEnumerable<string>>(rangeIds);
}

public override Task<SemanticRerankResult> SemanticRerankAsync(
string rerankContext,
IEnumerable<string> documents,
IDictionary<string, object> options,
CancellationToken cancellationToken = default)
=> throw new InMemoryCosmosException(
"SemanticRerankAsync is not supported by the in-memory emulator. " +
"This API requires a live Cosmos DB endpoint with semantic ranking capabilities.",
HttpStatusCode.BadRequest,
0,
Guid.NewGuid().ToString(),
SyntheticRequestCharge);
#endif

// ═══════════════════════════════════════════════════════════════════════════
// Container management
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
17 changes: 17 additions & 0 deletions src/CosmosDB.InMemoryEmulator/PartitionKeyCapturingContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,28 @@ public override ChangeFeedProcessorBuilder GetChangeFeedEstimatorBuilder(
string processorName, ChangesEstimationHandler estimationDelegate, TimeSpan? estimationPeriod = null)
=> _inner.GetChangeFeedEstimatorBuilder(processorName, estimationDelegate, estimationPeriod);

#if COSMOS_SDK_PREVIEW_METHODS
public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes<T>(
string processorName, ChangeFeedHandler<ChangeFeedItem<T>> onChangesDelegate)
=> _inner.GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes(processorName, onChangesDelegate);
#endif

// ── Feed ranges ─────────────────────────────────────────────────

public override Task<IReadOnlyList<FeedRange>> GetFeedRangesAsync(CancellationToken cancellationToken = default)
=> _inner.GetFeedRangesAsync(cancellationToken);

#if COSMOS_SDK_PREVIEW_METHODS
public override Task<IEnumerable<string>> GetPartitionKeyRangesAsync(
FeedRange feedRange, CancellationToken cancellationToken = default)
=> _inner.GetPartitionKeyRangesAsync(feedRange, cancellationToken);

public override Task<SemanticRerankResult> SemanticRerankAsync(
string rerankContext, IEnumerable<string> documents,
IDictionary<string, object> options, CancellationToken cancellationToken = default)
=> _inner.SemanticRerankAsync(rerankContext, documents, options, cancellationToken);
#endif

// ── Batch ───────────────────────────────────────────────────────

public override TransactionalBatch CreateTransactionalBatch(PartitionKey partitionKey)
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<!-- Shared NuGet package metadata for all source (packable) projects -->
<PropertyGroup>
<IsPackable>true</IsPackable>
<Version>4.0.5</Version>
<Version>4.0.7</Version>
<Authors>lemonlion</Authors>
<Copyright>Copyright (c) 2026 lemonlion</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
Loading