using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using UnityEditor;
#if UNITY_6000_5_OR_NEWER
using Unity.Scripting.LifecycleManagement;
#endif
namespace Unity.Pipeline.Editor.Commands.Batch
{
///
/// The batch command (AUTHAPI-27): transactional multi-operation execution with cross-op
/// result references, atomic Undo, and dry-run.
///
/// WHY it exists: a "create → add components → wire fields" job is otherwise 10–90 sequential
/// calls at ~0.8s each, and eval — the only alternative today — leaves partial state when it
/// throws mid-way, which is unacceptable on user-owned scenes. batch collapses the round
/// trips AND adds what eval structurally lacks: atomicity/rollback, dry-run validation, and
/// cross-step references.
///
/// Scope guard (epic AUTHAPI-24 design principle): batch is transactional object CRUD with
/// references. It deliberately has NO invoke/loop/conditional op types — that path reinvents
/// eval inside JSON and collapses the auditability story. Arbitrary logic belongs in
/// run_script (code as files on disk), not here.
///
/// Dispatch reuse: each sub-operation runs through
/// , the exact parameter
/// extraction / required-validation / reflection-invoke path /api/exec uses, so every per-op
/// result is shaped identically to running that command standalone.
///
/// Atomicity: the whole batch runs inside one , so all ops
/// (each of which registers its own Undo operations) collapse into a single Editor Undo step. When
/// transactional (the default) and an op fails under on_error=abort, every applied op
/// is reverted with — the scene returns to its
/// pre-batch state and there is nothing left on the Undo stack. Rollback is only real for
/// Undo-tracked mutations, which is why commands that mutate state OUTSIDE the Undo system
/// () are rejected in a transactional batch — see that field's
/// doc for the v1 caveat.
///
/// Timing: the batch executes in a single main-thread turn (no frame-spanning), so the
/// Editor UI is frozen for its duration. time_budget_ms is the cooperative bound on that
/// freeze: the elapsed time is checked before each operation after the first, and when the budget
/// is exhausted the remaining ops are skipped and a transactional batch rolls back. Long batches
/// should be submitted as detached jobs ("job": true) — cancellation is checked between
/// ops, and per-op progress is reported via .
///
#if UNITY_6000_5_OR_NEWER
[NoAutoStaticsCleanup]
#endif
static class BatchCommand
{
/// Hard cap on operations per batch (payload bound).
private const int MaxOperations = 200;
/// Default cooperative time budget for one batch execution (see ).
private const int DefaultTimeBudgetMs = 50_000;
/// Upper bound on time_budget_ms (1 hour).
private const int MaxTimeBudgetMs = 3_600_000;
///
/// Per-op result size (serialized UTF-8 BYTES — what actually goes on the wire, not UTF-16
/// chars, which undercount CJK/emoji-heavy payloads 3x) above which the result is replaced
/// by a truncation marker so a large sub-result cannot bloat the batch envelope.
///
private const int MaxPerOpResultBytes = 16 * 1024;
///
/// Aggregate budget for ALL per-op results in one batch response (UTF-8 bytes). Bounds the
/// whole envelope: 200 ops each just under the per-op cap would otherwise produce a ~3.2 MB
/// reply. Once exhausted, later results are replaced by truncation markers (the ops still
/// execute normally — only the echoed result payload is elided).
///
internal const int MaxBatchResultBytes = 256 * 1024;
///
/// Operation ids must look like identifiers: they may not be purely numeric (a numeric id
/// would be ambiguous with a 0-based index in a "$<id-or-index>" reference) and
/// may not contain '.' (the reference path separator).
///
private static readonly Regex m_IdPattern = new Regex("^[A-Za-z_][A-Za-z0-9_-]*$", RegexOptions.Compiled);
///
/// Commands excluded from batch (structured not_batchable error). These either escape
/// the "object CRUD with references" scope (build/platform/package/play-mode/recompile), carry
/// arbitrary code that belongs in run_script (eval/eval_file/run_script),
/// can pop modal dialogs that wedge an unattended batch (menu), or would nest a batch
/// inside a batch. (The ticket names eval; eval_file is its file-based twin and
/// is excluded on the same grounds.)
///
private static readonly HashSet m_ExcludedCommands = new HashSet(StringComparer.Ordinal)
{
"build", "switch_build_target",
"editor_play", "editor_stop", "editor_pause",
"recompile", "eval", "eval_file", "run_script", "menu", "batch",
// A synchronous wait inside a batch cannot make progress: the batch holds the main
// thread for its whole turn, so the editor state the condition watches cannot change —
// the wait just burns its full timeout inside the batch. Use an async wait_for before
// or after the batch instead. (wait_status/wait_cancel are quick reads and stay
// batchable.)
"wait_for"
};
/// Excluded command families matched by name prefix (e.g. all package_*).
private static readonly string[] m_ExcludedPrefixes = { "package_" };
///
/// Commands that CLEAR the Editor Undo stack, destroying the batch's ability to revert any
/// prior operation. Always rejected inside a batch (even with transactional=false the
/// single-Undo-step contract would silently break).
///
/// V1 CAVEAT: like below, this is a curated list —
/// any new command that clears or truncates the Undo stack must be added here, or it will
/// silently break the revert-integrity contract. The durable fix is command-site metadata
/// ([CliCommand(Revertible = ...)]).
///
private static readonly HashSet m_UndoStackWipingCommands = new HashSet(StringComparer.Ordinal)
{
"open_scene", "create_scene"
};
///
/// Commands whose mutations live OUTSIDE the Editor Undo system — asset/file writes
/// (AssetDatabase / File IO), scene saves, Build Settings, project settings — so
/// cannot roll them back. In a transactional
/// batch they are rejected (not_batchable_transactional): allowing them would make
/// reverted: true a lie. With transactional=false they run, and their per-op
/// result reports revertible: false.
///
/// V1 CAVEAT: this is a curated list, assembled by auditing each command's
/// implementation (AssetDatabase.SaveAssets / File writes / settings assets). The durable fix
/// is a [CliCommand(Revertible = ...)] metadata flag declared at the command site so
/// new commands can't silently fall through; until then, new non-Undo-tracked commands must
/// be added here.
///
private static readonly HashSet m_NonRevertibleCommands = new HashSet(StringComparer.Ordinal)
{
// Assets & files (AssetDatabase ops / direct file IO).
"create_asset", "import_asset", "move_asset", "copy_asset", "rename_asset", "delete_asset",
"create_folder", "set_import_settings", "write_text_file", "create_script",
// Prefab ASSET writes (scene-side prefab ops — instantiate/unpack/revert overrides — are
// Undo-tracked and stay batchable).
"create_prefab", "create_prefab_variant", "apply_prefab_overrides", "save_prefab_contents",
// Scene persistence and Build Settings scene list.
"save_scene", "save_all", "add_scene_to_build", "remove_scene_from_build", "set_build_settings",
// Project settings (their own descriptions say "Not undoable via Ctrl+Z").
"set_audio_settings", "set_graphics_settings", "set_input_settings", "set_lighting_settings",
"set_navmesh_settings", "set_physics_settings", "set_player_settings", "set_quality_settings",
"set_tags_layers", "set_time_settings", "set_authoring_root",
// Animation / Animator / Timeline commands persist their asset with AssetDatabase.SaveAssets.
"create_animation_clip", "set_animation_curve", "remove_animation_curve",
"create_animator_controller", "add_animator_layer", "add_animator_parameter",
"add_animator_state", "add_animator_transition",
"create_timeline", "add_timeline_track", "add_timeline_clip",
// Persists the .mat asset with AssetDatabase.SaveAssetIfDirty (the in-memory change is
// Undo-recorded, but the disk write is not reverted by Undo).
"set_material_properties",
// Baking writes/deletes baked data on disk (GI / NavMesh / occlusion).
"bake_lighting", "clear_baked_lighting",
"bake_navmesh", "bake_navmesh_surfaces", "clear_navmesh",
"bake_occlusion_culling", "clear_occlusion_culling"
};
[CliCommand("batch",
"Run multiple registered commands in one transactional request. Later ops can reference earlier " +
"results with \"$.\" (e.g. \"$0.instanceId\"; \"$$\" escapes a literal '$'). " +
"transactional=true (default) groups every op into one Undo step and reverts all applied ops if any " +
"op fails; commands that mutate outside the Undo system (asset/file/settings writes) are rejected " +
"unless transactional=false. on_error=abort|continue (continue forces transactional=false); dry_run " +
"validates without mutating; time_budget_ms (default 50000) bounds execution — when exceeded the " +
"remaining ops are skipped and a transactional batch rolls back (submit long batches with " +
"\"job\": true). Excludes build, switch_build_target, package_*, editor_play/stop/pause, recompile, " +
"eval, eval_file, run_script, menu, open_scene/create_scene, async commands (run_tests/list_tests), " +
"runtime-only commands, and nested batch.",
MainThreadRequired = true,
Tags = new[] { "batch" })]
public static BatchResult Batch(
[CliArg("operations", "Ordered operations to run; each is { id?, command, params }. Max 200.", Required = true)] List operations = null,
[CliArg("transactional", "Group all ops into one Undo step and revert every applied op if any op fails. Default true. Forced false when on_error=continue. Rejects commands whose mutations Undo cannot revert (asset/file/settings writes).")] bool transactional = true,
[CliArg("on_error", "abort (default): stop at the first failing op. continue: run every op, collecting per-op errors (forces transactional=false).")] string onError = "abort",
[CliArg("dry_run", "Validate command names, parameters, reference topology and excluded commands without mutating anything.")] bool dryRun = false,
[CliArg("result_fields", "Optional per-op result projection: map of op id-or-index -> array of result field paths to keep (context economy).")] JObject resultFields = null,
[CliArg("time_budget_ms", "Cooperative time budget for the whole batch in milliseconds (default 50000, max 3600000). Checked before each op after the first; when exhausted the remaining ops are skipped and a transactional batch rolls back. Use \"job\": true for long batches.")] int timeBudgetMs = DefaultTimeBudgetMs)
{
if (operations == null || operations.Count == 0)
throw new ArgumentException("batch 'operations' must contain at least one operation.");
if (operations.Count > MaxOperations)
throw new ArgumentException(
$"batch supports at most {MaxOperations} operations, but {operations.Count} were provided.");
if (timeBudgetMs < 0 || timeBudgetMs > MaxTimeBudgetMs)
throw new ArgumentException(
$"batch 'time_budget_ms' must be between 0 and {MaxTimeBudgetMs}, got {timeBudgetMs}.");
onError = string.IsNullOrWhiteSpace(onError) ? "abort" : onError.Trim().ToLowerInvariant();
if (onError != "abort" && onError != "continue")
throw new ArgumentException($"batch 'on_error' must be 'abort' or 'continue', got '{onError}'.");
// "continue" collects per-op errors and applies the independent ops, so it cannot also
// promise an all-or-nothing rollback — it forces transactional off (per the ticket).
var effectiveTransactional = transactional && onError != "continue";
var idToIndex = BuildAndValidateIdIndex(operations);
if (dryRun)
return DryRun(operations, idToIndex, effectiveTransactional, onError);
return Execute(operations, idToIndex, effectiveTransactional, onError, resultFields, timeBudgetMs);
}
///
/// Validate operation ids (well-formed and unique when supplied) and build the id → index map
/// used to resolve "$id.field" references.
///
private static Dictionary BuildAndValidateIdIndex(List operations)
{
var idToIndex = new Dictionary(StringComparer.Ordinal);
for (int i = 0; i < operations.Count; i++)
{
var op = operations[i];
if (op == null)
throw new ArgumentException($"batch operation at index {i} is null.");
if (op.Id == null)
continue;
if (!m_IdPattern.IsMatch(op.Id))
throw new ArgumentException(
$"batch operation id '{op.Id}' (index {i}) is invalid: ids must match " +
"^[A-Za-z_][A-Za-z0-9_-]*$ (start with a letter or '_'; letters, digits, '_' or '-' " +
"after). Purely numeric ids would be ambiguous with 0-based index selectors in " +
"\"$\" references, and '.' collides with the reference path separator.");
if (idToIndex.ContainsKey(op.Id))
throw new ArgumentException(
$"batch operation id '{op.Id}' is used more than once; ids must be unique within a batch.");
idToIndex[op.Id] = i;
}
return idToIndex;
}
///
/// Execute the batch: run each op (resolving its references against earlier results) inside one
/// Undo group, and on a transactional abort revert every applied op. Between ops, cooperative
/// cancellation and the time budget are checked, and per-op progress is reported.
///
private static BatchResult Execute(List operations, Dictionary idToIndex,
bool transactional, string onError, JObject resultFields, int timeBudgetMs)
{
var server = BasePipelineServer.CurrentServer;
if (server == null)
throw new InvalidOperationException(
"batch must run inside a pipeline server request (no active server on this thread).");
var result = new BatchResult { Transactional = transactional, OnError = onError };
// One name-indexed lookup per batch call instead of an O(commands) scan per op.
var commandsByName = BuildCommandIndex();
var resultsByIndex = new JToken[operations.Count];
var stoppedAt = -1; // index of the op that failed under on_error=abort
var skippedFrom = -1; // first op never attempted because of a batch-level abort (budget/cancel)
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var remainingResultBytes = MaxBatchResultBytes;
using (var undoScope = new AuthoringUndoScope("batch"))
{
var batchGroup = Undo.GetCurrentGroup();
result.UndoGroup = batchGroup;
for (int i = 0; i < operations.Count; i++)
{
// Cooperative cancellation (detached jobs: POST /api/job/cancel) — abort between
// ops and let the transactional rollback below restore the pre-batch state.
if (PipelineCancellation.IsCancellationRequested)
{
result.Error = "batch canceled: cooperative cancellation was requested; " +
(transactional ? "applied operations were reverted and " : "") +
"remaining operations were skipped.";
skippedFrom = i;
break;
}
// Cooperative time budget: the batch holds the main thread for its whole duration,
// so this is the bound on how long the Editor stays frozen. Op 0 always gets to
// run; every later op requires remaining budget.
if (i > 0 && stopwatch.ElapsedMilliseconds >= timeBudgetMs)
{
result.Error = $"batch time budget exceeded: {stopwatch.ElapsedMilliseconds}ms elapsed " +
$"after {i} of {operations.Count} operations (time_budget_ms={timeBudgetMs}). " +
(transactional ? "Applied operations were reverted and remaining operations were skipped. "
: "Remaining operations were skipped. ") +
"For long batches submit the request with \"job\": true and a larger time_budget_ms.";
skippedFrom = i;
break;
}
var op = operations[i];
CliProgress.Report("batch", $"op {i + 1}/{operations.Count}: {op.Command}",
i, operations.Count, i / (double)operations.Count);
var opResult = new BatchOperationResult { Id = op.Id, Command = op.Command };
result.Results.Add(opResult);
try
{
var command = ResolveBatchable(op.Command, transactional, commandsByName);
opResult.Revertible = !m_NonRevertibleCommands.Contains(op.Command);
var resolvedParams = BatchReferenceResolver.Resolve(
op.Params, i, operations, idToIndex, resultsByIndex, result.Results);
// Dispatched WITHOUT re-checking the server's busy gate (GetBusyReason):
// the batch itself was admitted through it in HandleExecRequest, the settle
// gate is a one-way latch that never re-arms mid-session, and the batch
// holds the main thread for its entire turn — the Editor cannot transition
// into a busy state between two ops of the same batch.
var raw = server.DispatchCommandOnMainThread(command, resolvedParams);
// Capture the result as a token for later references. A serialization failure
// here must NOT fail an op that already ran — leave the slot null (a later
// reference into it will error clearly) and still report success.
try
{
resultsByIndex[i] = raw == null ? JValue.CreateNull() : JToken.FromObject(raw);
}
catch
{
resultsByIndex[i] = null;
}
opResult.Success = true;
opResult.Result = ProjectResult(raw, resultsByIndex[i], op, i, resultFields, opResult, ref remainingResultBytes);
result.Applied++;
}
catch (Exception ex)
{
opResult.Success = false;
opResult.Error = ex.Message;
if (onError == "abort")
{
stoppedAt = i;
break;
}
// on_error=continue: keep going with the remaining independent ops.
}
}
if ((stoppedAt >= 0 || skippedFrom >= 0) && transactional)
{
// Revert every mutation registered since the batch started — one atomic rollback
// to the pre-batch state (nothing is left on the Undo stack). Deliberately NOT
// conditioned on Applied > 0: a FIRST op that partially mutates and then throws
// has registered exactly the Undo operations this revert must unwind, and
// reverting an empty group is harmless.
Undo.RevertAllDownToGroup(batchGroup);
result.Reverted = true;
// The revert discarded batchGroup from the Undo stack; the scope must not
// collapse a group id that no longer refers to this batch's operations.
undoScope.Cancel();
}
}
// Record the operations that were never attempted because the batch aborted (op failure
// under on_error=abort, cancellation, or an exhausted time budget), so the caller can see
// exactly where it stopped.
var firstSkipped = skippedFrom >= 0 ? skippedFrom
: stoppedAt >= 0 ? stoppedAt + 1
: operations.Count;
for (int j = firstSkipped; j < operations.Count; j++)
{
result.Results.Add(new BatchOperationResult
{
Id = operations[j].Id,
Command = operations[j].Command,
Success = false,
Skipped = true
});
}
return result;
}
///
/// Validate every operation without executing any of it: command names, that no excluded /
/// non-batchable command is used (including the transactional non-revertible rejection),
/// unknown/missing parameters, and reference topology (unknown/forward refs). Mutates nothing.
///
private static BatchResult DryRun(List operations, Dictionary idToIndex,
bool transactional, string onError)
{
var result = new BatchResult
{
DryRun = true,
Valid = true,
Transactional = transactional,
OnError = onError
};
var commandsByName = BuildCommandIndex();
for (int i = 0; i < operations.Count; i++)
{
var op = operations[i];
var opResult = new BatchOperationResult { Id = op.Id, Command = op.Command };
result.Results.Add(opResult);
try
{
var command = ResolveBatchable(op.Command, transactional, commandsByName);
opResult.Revertible = !m_NonRevertibleCommands.Contains(op.Command);
ValidateKnownParameters(command, op.Params);
ValidateRequiredParameters(command, op.Params);
BatchReferenceResolver.Validate(op.Params, i, operations, idToIndex);
opResult.Success = true; // would run
}
catch (Exception ex)
{
opResult.Success = false;
opResult.Error = ex.Message;
result.Valid = false;
}
}
return result;
}
///
/// Resolve a command name to its , rejecting non-batchable commands:
/// the excluded list (not_batchable), Undo-stack-wiping scene loads, runtime-only
/// commands, async (Task-returning) commands — which are completed by
/// EditorApplication.update callbacks that can never fire while the batch holds the
/// main thread, so awaiting them inline would freeze the Editor permanently — and, when
/// , commands whose mutations Undo cannot revert
/// (not_batchable_transactional). Unknown commands get an unknown command error.
/// Uses the registry directly (no server-side logging) so dry-run validation of an
/// intentionally bad name stays quiet.
///
///
/// Name-indexed snapshot of the registered commands, built once per batch call (the registry
/// list itself is cached; this avoids an O(commands) scan per op). NOTE: this deliberately
/// does not reuse BasePipelineServer.ResolveCommand — that helper serves the exec
/// request path (logging, envelope error shape); unifying the two belongs to a follow-up so
/// the server's hot path is not reshaped from this command.
///
private static Dictionary BuildCommandIndex()
{
var byName = new Dictionary(StringComparer.Ordinal);
foreach (var c in CommandRegistry.DiscoverCommands())
if (!byName.ContainsKey(c.Name))
byName.Add(c.Name, c);
return byName;
}
private static CommandInfo ResolveBatchable(string name, bool transactional, IReadOnlyDictionary byName)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("operation 'command' is required.");
if (IsExcluded(name))
throw new ArgumentException(
$"Command '{name}' is not batchable (not_batchable): commands that build, switch platform, " +
"manage packages, control play mode, recompile, evaluate code, execute Editor menu items " +
"(which can open modal dialogs and wedge an unattended batch), or nest a batch are excluded.");
if (m_UndoStackWipingCommands.Contains(name))
throw new ArgumentException(
$"Command '{name}' is not batchable (not_batchable): it clears the Editor Undo stack, which " +
"would make every prior batch operation unrevertible. Call it standalone instead.");
if (!byName.TryGetValue(name, out var command))
throw new ArgumentException($"No command named '{name}' is available (unknown command).");
if (command.RuntimeOnly)
throw new ArgumentException(
$"Command '{name}' is not batchable (not_batchable): runtime-only command (Player server " +
"surface); it cannot execute on this Editor server.");
if (typeof(Task).IsAssignableFrom(command.Method.ReturnType))
throw new ArgumentException(
$"Command '{name}' is not batchable (not_batchable): async commands cannot run inside a " +
"synchronous batch. They complete via EditorApplication.update callbacks, which cannot fire " +
"while the batch holds the main thread — the batch would deadlock the Editor. Call it " +
"standalone instead.");
if (transactional && m_NonRevertibleCommands.Contains(name))
throw new ArgumentException(
$"Command '{name}' is not batchable in a transactional batch (not_batchable_transactional): " +
"it mutates state outside the Editor Undo system (asset/file/settings writes), so a rollback " +
"could not revert it. Run the batch with transactional:false, or call the command standalone.");
return command;
}
private static bool IsExcluded(string name)
{
if (m_ExcludedCommands.Contains(name))
return true;
foreach (var prefix in m_ExcludedPrefixes)
{
if (name.StartsWith(prefix, StringComparison.Ordinal))
return true;
}
return false;
}
/// Reject parameter keys that are not parameters of the command (dry-run check).
private static void ValidateKnownParameters(CommandInfo command, JObject parameters)
{
if (parameters == null)
return;
var known = new HashSet(command.Parameters.Select(p => p.Name), StringComparer.Ordinal);
foreach (var prop in parameters.Properties())
{
if (!known.Contains(prop.Name))
throw new ArgumentException(
$"Command '{command.Name}' has no parameter '{prop.Name}' (unknown parameter). " +
$"Known parameters: [{string.Join(", ", known)}].");
}
}
///
/// Ensure every required parameter is present (dry-run check). A reference string counts as
/// present; an explicit JSON null does not.
///
private static void ValidateRequiredParameters(CommandInfo command, JObject parameters)
{
foreach (var p in command.Parameters)
{
if (!p.Required)
continue;
var token = parameters?[p.Name];
if (token == null || token.Type == JTokenType.Null)
throw new ArgumentException(
$"Command '{command.Name}' is missing required parameter '{p.Name}'.");
}
}
///
/// Shape an op's result: apply the optional result_fields projection, then replace an
/// oversized result with a truncation marker. With neither in play the raw result object is
/// returned unchanged, so it serializes byte-identically to the standalone command.
///
internal static object ProjectResult(object raw, JToken rawToken, BatchOperationInput op, int index,
JObject resultFields, BatchOperationResult opResult, ref int remainingResultBytes)
{
object projected = raw;
var fields = LookupResultFields(resultFields, op, index);
if (fields != null && rawToken != null && rawToken.Type != JTokenType.Null)
{
var projection = new JObject();
List invalidPaths = null;
foreach (var path in fields)
{
if (string.IsNullOrEmpty(path))
continue;
JToken value;
try
{
value = rawToken.SelectToken(path);
}
catch (Exception)
{
// A malformed JSONPath must NEVER throw here: by this point the op has
// already run and mutated state, and the outer catch would flip its
// Success to false — triggering a rollback of a succeeded op under
// on_error=abort, and poisoning later $refs into a perfectly valid
// captured result under on_error=continue. Report the bad path in the
// projection instead.
(invalidPaths ?? (invalidPaths = new List())).Add(path);
continue;
}
if (value != null)
projection[path] = value;
}
if (invalidPaths != null)
projection["invalidResultFields"] = new JArray(invalidPaths);
projected = projection;
}
string json;
try
{
// Measure with the same formatting the wire envelope uses: since the lean envelope
// (AUTHAPI-21) /api/exec serializes compact (Formatting.None) in every mode, so the
// measurement matches what actually goes on the wire.
json = JsonConvert.SerializeObject(projected, Formatting.None);
}
catch (Exception ex)
{
// A result Newtonsoft cannot serialize would fail (or bloat) the whole batch envelope
// later. Replace it with a structured marker — and still flag resultTruncated — rather
// than skipping truncation for exactly the results we know nothing about.
opResult.ResultTruncated = true;
return new JObject
{
["resultTruncated"] = true,
["error"] = $"op result could not be serialized: {ex.Message}"
};
}
var bytes = System.Text.Encoding.UTF8.GetByteCount(json);
if (bytes > MaxPerOpResultBytes)
{
opResult.ResultTruncated = true;
return new JObject
{
["resultTruncated"] = true,
["length"] = bytes,
["preview"] = json.Substring(0, Math.Min(json.Length, 512))
};
}
if (bytes > remainingResultBytes)
{
// Aggregate budget exhausted: the op executed normally, only its echoed result is
// elided so the whole batch reply stays bounded ($refs still resolve — they read
// the captured token, not this projection).
opResult.ResultTruncated = true;
return new JObject
{
["resultTruncated"] = true,
["length"] = bytes,
["error"] = $"batch aggregate result budget ({MaxBatchResultBytes} bytes) exhausted; " +
"the operation ran and later references to it still resolve, only this echoed result was elided. " +
"Use result_fields to project results down, or split the batch.",
["preview"] = json.Substring(0, Math.Min(json.Length, 512))
};
}
remainingResultBytes -= bytes;
return projected;
}
///
/// Look up the projection field list for an op in result_fields (keyed by op id, then
/// by index). Returns null when no projection is configured for the op.
///
private static string[] LookupResultFields(JObject resultFields, BatchOperationInput op, int index)
{
if (resultFields == null)
return null;
JToken entry = null;
if (!string.IsNullOrEmpty(op.Id))
entry = resultFields[op.Id];
if (entry == null)
entry = resultFields[index.ToString()];
if (entry == null)
return null;
if (entry.Type == JTokenType.Array)
return entry.ToObject();
if (entry.Type == JTokenType.String)
return new[] { (string)entry };
return null;
}
}
}