diff --git a/eng/testing/DesktopBatchRunner.cmd b/eng/testing/DesktopBatchRunner.cmd
new file mode 100644
index 00000000000000..13b0f8831bdf7f
--- /dev/null
+++ b/eng/testing/DesktopBatchRunner.cmd
@@ -0,0 +1,77 @@
+@echo off
+setlocal enabledelayedexpansion
+
+set "BATCH_DIR=%CD%"
+set /a SUITE_COUNT=0
+set /a FAIL_COUNT=0
+set "FOUND_ZIP="
+set "PYTHON=%HELIX_PYTHONPATH%"
+if "%PYTHON%"=="" set "PYTHON=python"
+
+if "%HELIX_WORKITEM_UPLOAD_ROOT%"=="" (
+ set "ORIGINAL_UPLOAD_ROOT=%CD%\test-results"
+) else (
+ set "ORIGINAL_UPLOAD_ROOT=%HELIX_WORKITEM_UPLOAD_ROOT%"
+)
+
+echo === DesktopBatchRunner ===
+echo BATCH_DIR=%BATCH_DIR%
+echo ORIGINAL_UPLOAD_ROOT=%ORIGINAL_UPLOAD_ROOT%
+
+for %%z in ("%BATCH_DIR%\*.zip") do (
+ if exist "%%~fz" (
+ set "FOUND_ZIP=1"
+ set "suiteName=%%~nz"
+ set "suiteDir=%BATCH_DIR%\!suiteName!"
+ set "suiteExitCode=0"
+
+ echo.
+ echo ========================= BEGIN !suiteName! =============================
+
+ mkdir "!suiteDir!" >nul 2>nul
+ "%PYTHON%" -c "import zipfile,sys; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" "%%~fz" "!suiteDir!"
+ if !errorlevel! neq 0 (
+ echo ERROR: Failed to extract %%~fz
+ set "suiteExitCode=1"
+ ) else (
+ set "HELIX_WORKITEM_UPLOAD_ROOT=!ORIGINAL_UPLOAD_ROOT!\!suiteName!"
+ mkdir "!HELIX_WORKITEM_UPLOAD_ROOT!" >nul 2>nul
+
+ pushd "!suiteDir!"
+ call RunTests.cmd %*
+ set "suiteExitCode=!errorlevel!"
+ popd
+ )
+
+ rmdir /s /q "!suiteDir!" 2>nul
+
+ set /a SUITE_COUNT+=1
+
+ if !suiteExitCode! neq 0 (
+ set /a FAIL_COUNT+=1
+ echo ----- FAIL !suiteName! - exit code !suiteExitCode! -----
+ ) else (
+ echo ----- PASS !suiteName! -----
+ )
+
+ echo ========================= END !suiteName! ===============================
+ )
+)
+
+set "HELIX_WORKITEM_UPLOAD_ROOT=%ORIGINAL_UPLOAD_ROOT%"
+
+if not defined FOUND_ZIP (
+ echo No .zip files found in %BATCH_DIR%
+ exit /b 1
+)
+
+echo.
+echo === Batch Summary ===
+set /a PASS_COUNT=SUITE_COUNT-FAIL_COUNT
+echo Total: %SUITE_COUNT% ^| Passed: %PASS_COUNT% ^| Failed: %FAIL_COUNT%
+
+if %FAIL_COUNT% neq 0 (
+ exit /b 1
+)
+
+exit /b 0
diff --git a/eng/testing/DesktopBatchRunner.sh b/eng/testing/DesktopBatchRunner.sh
new file mode 100644
index 00000000000000..6bb645c9599b7d
--- /dev/null
+++ b/eng/testing/DesktopBatchRunner.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+
+if [[ -z "${HELIX_WORKITEM_UPLOAD_ROOT:-}" ]]; then
+ ORIGINAL_UPLOAD_ROOT="$PWD/test-results"
+else
+ ORIGINAL_UPLOAD_ROOT="$HELIX_WORKITEM_UPLOAD_ROOT"
+fi
+
+if [[ -n "${HELIX_PYTHONPATH:-}" ]]; then
+ PYTHON_CMD=("$HELIX_PYTHONPATH")
+elif command -v python3 >/dev/null 2>&1; then
+ PYTHON_CMD=(python3)
+else
+ PYTHON_CMD=(python)
+fi
+
+BATCH_DIR="$PWD"
+SUITE_COUNT=0
+FAIL_COUNT=0
+SUITE_NAMES=()
+SUITE_EXIT_CODES=()
+SUITE_DURATIONS=()
+
+echo "=== DesktopBatchRunner ==="
+echo "BATCH_DIR=$BATCH_DIR"
+echo "ORIGINAL_UPLOAD_ROOT=$ORIGINAL_UPLOAD_ROOT"
+
+for zipFile in "$BATCH_DIR"/*.zip; do
+ if [[ ! -f "$zipFile" ]]; then
+ echo "No .zip files found in $BATCH_DIR"
+ exit 1
+ fi
+
+ suiteName=$(basename "$zipFile" .zip)
+ suiteDir="$BATCH_DIR/$suiteName"
+
+ echo ""
+ echo "========================= BEGIN $suiteName ============================="
+
+ mkdir -p "$suiteDir"
+ "${PYTHON_CMD[@]}" -c "import zipfile,sys; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" "$zipFile" "$suiteDir"
+ unzipExitCode=$?
+ if [[ $unzipExitCode -ne 0 ]]; then
+ echo "ERROR: Failed to extract $zipFile (exit code: $unzipExitCode)"
+ FAIL_COUNT=$((FAIL_COUNT + 1))
+ SUITE_NAMES+=("$suiteName")
+ SUITE_EXIT_CODES+=("$unzipExitCode")
+ SUITE_DURATIONS+=("0")
+ SUITE_COUNT=$((SUITE_COUNT + 1))
+ rm -rf "$suiteDir"
+ continue
+ fi
+
+ export HELIX_WORKITEM_UPLOAD_ROOT="$ORIGINAL_UPLOAD_ROOT/$suiteName"
+ mkdir -p "$HELIX_WORKITEM_UPLOAD_ROOT"
+
+ pushd "$suiteDir" >/dev/null
+
+ chmod +x RunTests.sh
+
+ startTime=$(date +%s)
+ ./RunTests.sh "$@"
+ suiteExitCode=$?
+ endTime=$(date +%s)
+
+ popd >/dev/null
+
+ rm -rf "$suiteDir"
+
+ duration=$((endTime - startTime))
+
+ SUITE_NAMES+=("$suiteName")
+ SUITE_EXIT_CODES+=("$suiteExitCode")
+ SUITE_DURATIONS+=("$duration")
+ SUITE_COUNT=$((SUITE_COUNT + 1))
+
+ if [[ $suiteExitCode -ne 0 ]]; then
+ FAIL_COUNT=$((FAIL_COUNT + 1))
+ echo "----- FAIL $suiteName - exit code $suiteExitCode - ${duration}s -----"
+ else
+ echo "----- PASS $suiteName - ${duration}s -----"
+ fi
+
+ echo "========================= END $suiteName ==============================="
+done
+
+export HELIX_WORKITEM_UPLOAD_ROOT="$ORIGINAL_UPLOAD_ROOT"
+
+echo ""
+echo "=== Batch Summary ==="
+printf "%-60s %-6s %s\n" "Suite" "Status" "Duration"
+printf "%-60s %-6s %s\n" "-----" "------" "--------"
+
+for i in "${!SUITE_NAMES[@]}"; do
+ if [[ ${SUITE_EXIT_CODES[$i]} -eq 0 ]]; then
+ status="PASS"
+ else
+ status="FAIL"
+ fi
+ printf "%-60s %-6s %ss\n" "${SUITE_NAMES[$i]}" "$status" "${SUITE_DURATIONS[$i]}"
+done
+
+echo ""
+echo "Total: $SUITE_COUNT | Passed: $((SUITE_COUNT - FAIL_COUNT)) | Failed: $FAIL_COUNT"
+
+if [[ $FAIL_COUNT -ne 0 ]]; then
+ exit 1
+fi
+
+exit 0
diff --git a/src/libraries/pretest.proj b/src/libraries/pretest.proj
index 7b53dffc05900c..7ee882806626d6 100644
--- a/src/libraries/pretest.proj
+++ b/src/libraries/pretest.proj
@@ -24,8 +24,8 @@
-
-
+
+
diff --git a/src/libraries/sendtohelixhelp.proj b/src/libraries/sendtohelixhelp.proj
index e9d59bc6be2b28..d1c5bd342411b4 100644
--- a/src/libraries/sendtohelixhelp.proj
+++ b/src/libraries/sendtohelixhelp.proj
@@ -16,6 +16,9 @@
BuildHelixCommand;StageDependenciesForHelix
true
$([MSBuild]::NormalizeDirectory($(ArtifactsObjDir), 'helix-staging'))
+ true
+ 20
+ <_LibraryHelixBatchLargeThreshold Condition="'$(_LibraryHelixBatchLargeThreshold)' == ''">52428800
@@ -23,6 +26,13 @@
+
+
+
- $(HelixCommand) powershell -command "dotnet dev-certs https --export-path devcerts.pfx --password PLACEHOLDER %3B $pw = ConvertTo-SecureString PLACEHOLDER -AsPlainText -Force %3B Import-PfxCertificate -FilePath devcerts.pfx -Password $pw -CertStoreLocation Cert:\LocalMachine\Root " &&
+ $(HelixCommandPrefix) powershell -command "dotnet dev-certs https --export-path devcerts.pfx --password PLACEHOLDER %3B $pw = ConvertTo-SecureString PLACEHOLDER -AsPlainText -Force %3B Import-PfxCertificate -FilePath devcerts.pfx -Password $pw -CertStoreLocation Cert:\LocalMachine\Root " &&
- $(HelixCommand)call RunTests.cmd
- $(HelixCommand) --runtime-path %HELIX_CORRELATION_PAYLOAD%
-
- $(HelixCommand)./RunTests.sh
- $(HelixCommand) --runtime-path "$HELIX_CORRELATION_PAYLOAD"
+ call RunTests.cmd
+ call DesktopBatchRunner.cmd
+ $(HelixRunnerEntryPoint) --runtime-path %HELIX_CORRELATION_PAYLOAD%
+ $(HelixBatchRunnerEntryPoint) --runtime-path %HELIX_CORRELATION_PAYLOAD%
+
+ ./RunTests.sh
+ chmod +x DesktopBatchRunner.sh && ./DesktopBatchRunner.sh
+ $(HelixRunnerEntryPoint) --runtime-path "$HELIX_CORRELATION_PAYLOAD"
+ $(HelixBatchRunnerEntryPoint) --runtime-path "$HELIX_CORRELATION_PAYLOAD"
+
+ $(HelixCommandPrefix)
+ $(HelixCommand)$(HelixRunnerEntryPoint)
+ <_BatchHelixCommand Condition="'$(HelixCommandPrefix)' != ''">$(HelixCommandPrefix)
+ <_BatchHelixCommand>$(_BatchHelixCommand)$(HelixBatchRunnerEntryPoint)
@@ -300,7 +319,7 @@
-
+
@@ -315,6 +334,90 @@
+
+
+
+ <_DefaultWorkItems Include="$(WorkItemArchiveWildCard)" Exclude="$(HelixCorrelationPayload)">
+ true
+ false
+ Scenario=$(Scenario)
+ Desktop
+ Windows
+ Unix
+ $(RuntimeFlavor)
+ $(TargetOS)
+ $(TargetArchitecture)
+ normal
+ $(Scenario)
+ Normal
+ Stress
+ DesktopRuntime
+
+
+ <_BatchCandidateWorkItems Include="@(_DefaultWorkItems)" Condition="'%(_DefaultWorkItems.BatchCompatible)' == 'true'" />
+ <_NonBatchWorkItems Include="@(_DefaultWorkItems)" Condition="'%(_DefaultWorkItems.BatchCompatible)' != 'true'" />
+
+
+ %(_NonBatchWorkItems.Identity)
+ $(HelixCommand)
+ $(_workItemTimeout)
+ $(SuperPmiCollectionName).$(SuperPmiCollectionType).$(TargetOS).$(TargetArchitecture).$(Configuration).mch;$(SuperPmiCollectionName).$(SuperPmiCollectionType).$(TargetOS).$(TargetArchitecture).$(Configuration).log
+
+
+
+
+
+
+
+
+ <_BatchId Include="@(_BatchGroupedItem -> '%(BatchId)')" />
+ <_UniqueBatchId Include="@(_BatchId->Distinct())" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(IntermediateOutputPath)helix-batches/%(Identity).zip
+ $(_BatchHelixCommand)
+
+
+
diff --git a/src/tasks/HelixTestTasks.Tests/GroupWorkItemsTests.cs b/src/tasks/HelixTestTasks.Tests/GroupWorkItemsTests.cs
new file mode 100644
index 00000000000000..77233540537efa
--- /dev/null
+++ b/src/tasks/HelixTestTasks.Tests/GroupWorkItemsTests.cs
@@ -0,0 +1,174 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+using Microsoft.DotNet.HelixTestTasks;
+using Xunit;
+
+namespace HelixTestTasks.Tests;
+
+public sealed class GroupWorkItemsTests : IDisposable
+{
+ private readonly string _testDirectory = Path.Combine(AppContext.BaseDirectory, "GroupWorkItemsTests", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_testDirectory))
+ {
+ Directory.Delete(_testDirectory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void PreservesGreedyPackingWhenCompatibilityMetadataIsNotConfigured()
+ {
+ ITaskItem[] result = Execute(
+ batchSize: 2,
+ largeThreshold: 1_000,
+ Item("a.zip", 100),
+ Item("b.zip", 50),
+ Item("c.zip", 25));
+
+ Assert.Equal("0", BatchId(result, "a.zip"));
+ Assert.Equal("1", BatchId(result, "b.zip"));
+ Assert.Equal("1", BatchId(result, "c.zip"));
+ }
+
+ [Fact]
+ public void PartitionsSmallItemsByCompatibilityMetadataBeforePacking()
+ {
+ ITaskItem normal1 = Item("normal1.zip", 100, ("Scenario", "normal"));
+ ITaskItem normal2 = Item("normal2.zip", 50, ("Scenario", "normal"));
+ ITaskItem stress1 = Item("stress1.zip", 90, ("Scenario", "stress"));
+ ITaskItem stress2 = Item("stress2.zip", 40, ("Scenario", "stress"));
+
+ ITaskItem[] result = Execute(
+ batchSize: 2,
+ largeThreshold: 1_000,
+ compatibilityMetadataKeys: "Scenario",
+ normal1,
+ normal2,
+ stress1,
+ stress2);
+
+ string[] normalBatchIds = { BatchId(result, "normal1.zip"), BatchId(result, "normal2.zip") };
+ string[] stressBatchIds = { BatchId(result, "stress1.zip"), BatchId(result, "stress2.zip") };
+
+ Assert.Empty(normalBatchIds.Intersect(stressBatchIds));
+ }
+
+ [Fact]
+ public void BatchCompatibleFalseItemsBecomeSoloBatches()
+ {
+ ITaskItem[] result = Execute(
+ batchSize: 2,
+ largeThreshold: 1_000,
+ compatibilityMetadataKeys: "RunnerType",
+ batchCompatibleMetadataKey: "BatchCompatible",
+ Item("one.zip", 100, ("RunnerType", "Desktop"), ("BatchCompatible", "true")),
+ Item("two.zip", 90, ("RunnerType", "Desktop"), ("BatchCompatible", "true")),
+ Item("stress.zip", 80, ("RunnerType", "Desktop"), ("BatchCompatible", "false")));
+
+ Assert.NotEqual(BatchId(result, "stress.zip"), BatchId(result, "one.zip"));
+ Assert.NotEqual(BatchId(result, "stress.zip"), BatchId(result, "two.zip"));
+ Assert.Single(result.Where(item => item.GetMetadata("BatchId") == BatchId(result, "stress.zip")));
+ }
+
+ [Fact]
+ public void LargeItemsKeepNegativeSoloBatchIds()
+ {
+ ITaskItem[] result = Execute(
+ batchSize: 2,
+ largeThreshold: 50,
+ Item("large.zip", 100),
+ Item("small.zip", 10));
+
+ Assert.Equal("-1", BatchId(result, "large.zip"));
+ Assert.Equal("0", BatchId(result, "small.zip"));
+ }
+
+ [Fact]
+ public void MissingCompatibilityMetadataIsSoloBatchedWithoutFailingByDefault()
+ {
+ ITaskItem[] result = Execute(
+ batchSize: 2,
+ largeThreshold: 1_000,
+ compatibilityMetadataKeys: "RunnerType",
+ Item("has-metadata.zip", 100, ("RunnerType", "Desktop")),
+ Item("missing-metadata.zip", 90));
+
+ Assert.NotEqual(BatchId(result, "has-metadata.zip"), BatchId(result, "missing-metadata.zip"));
+ }
+
+ private ITaskItem[] Execute(
+ int batchSize,
+ long largeThreshold,
+ params ITaskItem[] items) =>
+ Execute(batchSize, largeThreshold, compatibilityMetadataKeys: string.Empty, batchCompatibleMetadataKey: string.Empty, items);
+
+ private static ITaskItem[] Execute(
+ int batchSize,
+ long largeThreshold,
+ string compatibilityMetadataKeys,
+ params ITaskItem[] items) =>
+ Execute(batchSize, largeThreshold, compatibilityMetadataKeys, batchCompatibleMetadataKey: string.Empty, items);
+
+ private static ITaskItem[] Execute(
+ int batchSize,
+ long largeThreshold,
+ string compatibilityMetadataKeys,
+ string batchCompatibleMetadataKey,
+ params ITaskItem[] items)
+ {
+ var task = new GroupWorkItems
+ {
+ BuildEngine = new MockBuildEngine(),
+ Items = items,
+ BatchSize = batchSize,
+ LargeThreshold = largeThreshold,
+ CompatibilityMetadataKeys = compatibilityMetadataKeys,
+ BatchCompatibleMetadataKey = batchCompatibleMetadataKey,
+ };
+
+ Assert.True(task.Execute());
+ return task.GroupedItems;
+ }
+
+ private ITaskItem Item(string name, int size, params (string key, string value)[] metadata)
+ {
+ Directory.CreateDirectory(_testDirectory);
+ string path = Path.Combine(_testDirectory, name);
+ File.WriteAllBytes(path, new byte[size]);
+
+ var item = new TaskItem(path);
+ foreach ((string key, string value) in metadata)
+ {
+ item.SetMetadata(key, value);
+ }
+
+ return item;
+ }
+
+ private static string BatchId(IEnumerable items, string fileName) =>
+ items.Single(item => Path.GetFileName(item.ItemSpec) == fileName).GetMetadata("BatchId");
+
+ private sealed class MockBuildEngine : IBuildEngine
+ {
+ public bool ContinueOnError => false;
+ public int LineNumberOfTaskNode => 0;
+ public int ColumnNumberOfTaskNode => 0;
+ public string ProjectFileOfTaskNode => string.Empty;
+
+ public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => true;
+ public void LogCustomEvent(CustomBuildEventArgs e) { }
+ public void LogErrorEvent(BuildErrorEventArgs e) { }
+ public void LogMessageEvent(BuildMessageEventArgs e) { }
+ public void LogWarningEvent(BuildWarningEventArgs e) { }
+ }
+}
diff --git a/src/tasks/HelixTestTasks.Tests/HelixTestTasks.Tests.csproj b/src/tasks/HelixTestTasks.Tests/HelixTestTasks.Tests.csproj
new file mode 100644
index 00000000000000..eef4a495ebf56e
--- /dev/null
+++ b/src/tasks/HelixTestTasks.Tests/HelixTestTasks.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+
+ $(NetCoreAppToolCurrent)
+ true
+ false
+ enable
+ $(NoWarn);NETSDK1023
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/tasks/HelixTestTasks/GroupWorkItems.cs b/src/tasks/HelixTestTasks/GroupWorkItems.cs
index a66f85b69c4b74..edd84e5106261f 100644
--- a/src/tasks/HelixTestTasks/GroupWorkItems.cs
+++ b/src/tasks/HelixTestTasks/GroupWorkItems.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
@@ -18,6 +19,8 @@ namespace Microsoft.DotNet.HelixTestTasks;
///
public class GroupWorkItems : Task
{
+ private static readonly char[] s_compatibilityMetadataKeySeparators = { ';' };
+
[Required]
public ITaskItem[] Items { get; set; } = Array.Empty();
@@ -25,6 +28,12 @@ public class GroupWorkItems : Task
public long LargeThreshold { get; set; } = 52428800L; // 50 MB
+ public string CompatibilityMetadataKeys { get; set; } = string.Empty;
+
+ public string BatchCompatibleMetadataKey { get; set; } = string.Empty;
+
+ public bool StrictCompatibilityMetadata { get; set; }
+
[Output]
public ITaskItem[] GroupedItems { get; set; } = Array.Empty();
@@ -50,9 +59,15 @@ public override bool Execute()
var result = new List();
int negativeBatchId = -1;
+ int nextBatchId = 0;
+ string[] compatibilityKeys = CompatibilityMetadataKeys
+ .Split(s_compatibilityMetadataKeySeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ bool hasCompatibilityKeys = compatibilityKeys.Length > 0;
+ string batchCompatibleMetadataKey = BatchCompatibleMetadataKey.Trim();
// Separate large items (each gets its own batch)
- var smallItems = new List<(ITaskItem item, long size)>();
+ var partitionedSmallItems = new Dictionary>(StringComparer.Ordinal);
+ var soloSmallItems = new List<(ITaskItem item, long size)>();
foreach (var entry in itemsWithSize)
{
if (entry.size > LargeThreshold)
@@ -64,12 +79,25 @@ public override bool Execute()
}
else
{
- smallItems.Add(entry);
+ if (IsSolo(entry.item, batchCompatibleMetadataKey, compatibilityKeys, out string partitionKey))
+ {
+ soloSmallItems.Add(entry);
+ }
+ else
+ {
+ if (!partitionedSmallItems.TryGetValue(partitionKey, out List<(ITaskItem item, long size)>? partitionItems))
+ {
+ partitionItems = new List<(ITaskItem item, long size)>();
+ partitionedSmallItems.Add(partitionKey, partitionItems);
+ }
+
+ partitionItems.Add(entry);
+ }
}
}
// Greedy bin-packing for small items
- if (smallItems.Count > 0)
+ foreach (List<(ITaskItem item, long size)> smallItems in partitionedSmallItems.Values)
{
int numBatches = Math.Min(BatchSize, smallItems.Count);
var batchSizes = new long[numBatches];
@@ -88,15 +116,77 @@ public override bool Execute()
}
batchSizes[minIdx] += entry.size;
var newItem = new TaskItem(entry.item);
- newItem.SetMetadata("BatchId", minIdx.ToString());
+ newItem.SetMetadata("BatchId", (nextBatchId + minIdx).ToString());
batchAssignments[minIdx].Add(newItem);
}
for (int i = 0; i < numBatches; i++)
result.AddRange(batchAssignments[i]);
+
+ nextBatchId += numBatches;
+ }
+
+ foreach (var entry in soloSmallItems)
+ {
+ var newItem = new TaskItem(entry.item);
+ newItem.SetMetadata("BatchId", nextBatchId.ToString());
+ nextBatchId++;
+ result.Add(newItem);
}
GroupedItems = result.ToArray();
return !Log.HasLoggedErrors;
}
+
+ private bool IsSolo(ITaskItem item, string batchCompatibleMetadataKey, string[] compatibilityKeys, out string partitionKey)
+ {
+ partitionKey = string.Empty;
+
+ if (!string.IsNullOrEmpty(batchCompatibleMetadataKey))
+ {
+ string batchCompatible = item.GetMetadata(batchCompatibleMetadataKey);
+ if (!string.IsNullOrEmpty(batchCompatible) &&
+ !string.Equals(batchCompatible, "true", StringComparison.OrdinalIgnoreCase))
+ {
+ Log.LogMessage(
+ MessageImportance.Low,
+ "Keeping '{0}' in a solo batch because metadata '{1}' is '{2}'.",
+ item.ItemSpec,
+ batchCompatibleMetadataKey,
+ batchCompatible);
+ return true;
+ }
+ }
+
+ if (compatibilityKeys.Length == 0)
+ {
+ return false;
+ }
+
+ string[] values = new string[compatibilityKeys.Length];
+ for (int i = 0; i < compatibilityKeys.Length; i++)
+ {
+ string key = compatibilityKeys[i];
+ string value = item.GetMetadata(key);
+ if (string.IsNullOrEmpty(value))
+ {
+ string message = $"Keeping '{item.ItemSpec}' in a solo batch because required compatibility metadata '{key}' is missing.";
+ if (StrictCompatibilityMetadata)
+ {
+ Log.LogError(message);
+ }
+ else
+ {
+ Log.LogMessage(MessageImportance.High, message);
+ }
+
+ return true;
+ }
+
+ values[i] = value;
+ }
+
+ partitionKey = string.Join('\u001f', compatibilityKeys.Zip(values, static (key, value) => key + "=" + value));
+ return false;
+ }
}