Skip to content

Commit daa0939

Browse files
authored
improvement(container-runtime): Simplify dirty state management in ContainerRuntime (#24646)
Simplifies dirty state tracking by computing it consistently every time its value may need to be updated. This fixes a bug in Staging Mode where if upon discarding staged changes the only remaining pending op were "non-dirtyable", we would still call the container dirty. See tests in documentDirty.spec.ts
1 parent dceacde commit daa0939

File tree

9 files changed

+450
-102
lines changed

9 files changed

+450
-102
lines changed

packages/runtime/container-runtime/src/containerRuntime.ts

Lines changed: 79 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,7 +1282,7 @@ export class ContainerRuntime
12821282

12831283
/**
12841284
* Invokes the given callback and expects that no ops are submitted
1285-
* until execution finishes. If an op is submitted, an error will be raised.
1285+
* until execution finishes. If an op is submitted, it will be marked as reentrant.
12861286
*
12871287
* @param callback - the callback to be invoked
12881288
*/
@@ -1306,7 +1306,7 @@ export class ContainerRuntime
13061306
return this._disposed;
13071307
}
13081308

1309-
private dirtyContainer: boolean;
1309+
private lastEmittedDirty: boolean;
13101310
private emitDirtyDocumentEvent = true;
13111311
private readonly useDeltaManagerOpsProxy: boolean;
13121312
private readonly closeSummarizerDelayMs: number;
@@ -1532,8 +1532,8 @@ export class ContainerRuntime
15321532
this.mc.logger.sendTelemetryEvent({
15331533
eventName: "Attached",
15341534
details: {
1535-
dirtyContainer: this.dirtyContainer,
1536-
hasPendingMessages: this.hasPendingMessages(),
1535+
lastEmittedDirty: this.lastEmittedDirty,
1536+
currentDirtyState: this.computeCurrentDirtyState(),
15371537
},
15381538
});
15391539
});
@@ -1897,9 +1897,9 @@ export class ContainerRuntime
18971897
this.closeSummarizerDelayMs =
18981898
closeSummarizerDelayOverride ?? defaultCloseSummarizerDelayMs;
18991899

1900-
this.dirtyContainer =
1901-
this.attachState !== AttachState.Attached || this.hasPendingMessages();
1902-
context.updateDirtyContainerState(this.dirtyContainer);
1900+
// We haven't emitted dirty/saved yet, but this is the baseline so we know to emit when it changes
1901+
this.lastEmittedDirty = this.computeCurrentDirtyState();
1902+
context.updateDirtyContainerState(this.lastEmittedDirty);
19031903

19041904
if (!this.skipSafetyFlushDuringProcessStack) {
19051905
// Reference Sequence Number may have just changed, and it must be consistent across a batch,
@@ -2528,17 +2528,12 @@ export class ContainerRuntime
25282528
return;
25292529
}
25302530

2531-
// We need to temporary clear the dirty flags and disable
2532-
// dirty state change events to detect whether replaying ops
2533-
// has any effect.
2534-
2535-
// Save the old state, reset to false, disable event emit
2536-
const oldState = this.dirtyContainer;
2537-
this.dirtyContainer = false;
2538-
2531+
// Replaying is an internal operation and we don't want to generate noise while doing it.
2532+
// So temporarily disable dirty state change events, and save the old state.
2533+
// When we're done, we'll emit the event if the state changed.
2534+
const oldState = this.lastEmittedDirty;
25392535
assert(this.emitDirtyDocumentEvent, 0x127 /* "dirty document event not set on replay" */);
25402536
this.emitDirtyDocumentEvent = false;
2541-
let newState: boolean;
25422537

25432538
try {
25442539
// Any ID Allocation ops that failed to submit after the pending state was queued need to have
@@ -2550,14 +2545,13 @@ export class ContainerRuntime
25502545
// replay the ops
25512546
this.pendingStateManager.replayPendingStates();
25522547
} finally {
2553-
// Save the new start and restore the old state, re-enable event emit
2554-
newState = this.dirtyContainer;
2555-
this.dirtyContainer = oldState;
2548+
// Restore the old state, re-enable event emit
2549+
this.lastEmittedDirty = oldState;
25562550
this.emitDirtyDocumentEvent = true;
25572551
}
25582552

2559-
// Officially transition from the old state to the new state.
2560-
this.updateDocumentDirtyState(newState);
2553+
// This will emit an event if the state changed relative to before replay
2554+
this.updateDocumentDirtyState();
25612555
}
25622556

25632557
/**
@@ -2928,6 +2922,9 @@ export class ContainerRuntime
29282922
runtimeBatch: boolean,
29292923
groupedBatch: boolean,
29302924
): void {
2925+
// This message could have been the last pending local (dirtyable) message, in which case we need to update dirty state to "saved"
2926+
this.updateDocumentDirtyState();
2927+
29312928
if (locationInBatch.batchStart) {
29322929
const firstMessage = messagesWithMetadata[0]?.message;
29332930
assert(firstMessage !== undefined, 0xa31 /* Batch must have at least one message */);
@@ -3043,12 +3040,6 @@ export class ContainerRuntime
30433040

30443041
this._processedClientSequenceNumber = message.clientSequenceNumber;
30453042

3046-
// If there are no more pending messages after processing a local message,
3047-
// the document is no longer dirty.
3048-
if (!this.hasPendingMessages()) {
3049-
this.updateDocumentDirtyState(false);
3050-
}
3051-
30523043
// The DeltaManager used to do this, but doesn't anymore as of Loader v2.4
30533044
// Anyone listening to our "op" event would expect the contents to be parsed per this same logic
30543045
if (
@@ -3079,12 +3070,6 @@ export class ContainerRuntime
30793070
local: boolean,
30803071
savedOp?: boolean,
30813072
): void {
3082-
// If there are no more pending messages after processing a local message,
3083-
// the document is no longer dirty.
3084-
if (!this.hasPendingMessages()) {
3085-
this.updateDocumentDirtyState(false);
3086-
}
3087-
30883073
// Get the contents without the localOpMetadata because not all message types know about localOpMetadata.
30893074
const contents = messagesContent.map((c) => c.contents);
30903075

@@ -3239,7 +3224,6 @@ export class ContainerRuntime
32393224
*/
32403225
public orderSequentially<T>(callback: () => T): T {
32413226
let checkpoint: IBatchCheckpoint | undefined;
3242-
const checkpointDirtyState = this.dirtyContainer;
32433227
// eslint-disable-next-line import/no-deprecated
32443228
let stageControls: StageControlsExperimental | undefined;
32453229
if (this.mc.config.getBoolean("Fluid.ContainerRuntime.EnableRollback")) {
@@ -3261,10 +3245,7 @@ export class ContainerRuntime
32613245
checkpoint.rollback((message: LocalBatchMessage) =>
32623246
this.rollback(message.runtimeOp, message.localOpMetadata),
32633247
);
3264-
// reset the dirty state after rollback to what it was before to keep it consistent
3265-
if (this.dirtyContainer !== checkpointDirtyState) {
3266-
this.updateDocumentDirtyState(checkpointDirtyState);
3267-
}
3248+
this.updateDocumentDirtyState();
32683249
stageControls?.discardChanges();
32693250
stageControls = undefined;
32703251
} catch (error_) {
@@ -3360,9 +3341,7 @@ export class ContainerRuntime
33603341
);
33613342
this.rollback(runtimeOp, localOpMetadata);
33623343
});
3363-
if (this.attachState === AttachState.Attached) {
3364-
this.updateDocumentDirtyState(this.pendingMessagesCount !== 0);
3365-
}
3344+
this.updateDocumentDirtyState();
33663345
}),
33673346
commitChanges: (optionsParam) => {
33683347
const options = { ...defaultStagingCommitOptions, ...optionsParam };
@@ -3474,40 +3453,20 @@ export class ContainerRuntime
34743453
* either were not sent out to delta stream or were not yet acknowledged.
34753454
*/
34763455
public get isDirty(): boolean {
3477-
return this.dirtyContainer;
3456+
// Rather than recomputing the dirty state in this moment,
3457+
// just regurgitate the last emitted dirty state.
3458+
return this.lastEmittedDirty;
34783459
}
34793460

3480-
private isContainerMessageDirtyable({
3481-
type,
3482-
contents,
3483-
}: LocalContainerRuntimeMessage): boolean {
3484-
// Certain container runtime messages should not mark the container dirty such as the old built-in
3485-
// AgentScheduler and Garbage collector messages.
3486-
switch (type) {
3487-
case ContainerMessageType.Attach: {
3488-
const attachMessage = contents as InboundAttachMessage;
3489-
if (attachMessage.id === agentSchedulerId) {
3490-
return false;
3491-
}
3492-
break;
3493-
}
3494-
case ContainerMessageType.FluidDataStoreOp: {
3495-
const envelope = contents;
3496-
if (envelope.address === agentSchedulerId) {
3497-
return false;
3498-
}
3499-
break;
3500-
}
3501-
case ContainerMessageType.IdAllocation:
3502-
case ContainerMessageType.DocumentSchemaChange:
3503-
case ContainerMessageType.GC: {
3504-
return false;
3505-
}
3506-
default: {
3507-
break;
3508-
}
3509-
}
3510-
return true;
3461+
/**
3462+
* Returns true if the container is dirty: not attached, or no pending user messages (could be some "non-dirtyable" ones though)
3463+
*/
3464+
private computeCurrentDirtyState(): boolean {
3465+
return (
3466+
this.attachState !== AttachState.Attached ||
3467+
this.pendingStateManager.hasPendingUserChanges() ||
3468+
this.outbox.containsUserChanges()
3469+
);
35113470
}
35123471

35133472
/**
@@ -3545,9 +3504,7 @@ export class ContainerRuntime
35453504
this.emit("attached");
35463505
}
35473506

3548-
if (attachState === AttachState.Attached && !this.hasPendingMessages()) {
3549-
this.updateDocumentDirtyState(false);
3550-
}
3507+
this.updateDocumentDirtyState();
35513508
this.channelCollection.setAttachState(attachState);
35523509
}
35533510

@@ -4333,22 +4290,22 @@ export class ContainerRuntime
43334290
return this.pendingMessagesCount !== 0;
43344291
}
43354292

4336-
private updateDocumentDirtyState(dirty: boolean): void {
4337-
if (this.attachState === AttachState.Attached) {
4338-
// Other way is not true = see this.isContainerMessageDirtyable()
4339-
assert(
4340-
!dirty || this.hasPendingMessages(),
4341-
0x3d3 /* if doc is dirty, there has to be pending ops */,
4342-
);
4343-
} else {
4344-
assert(dirty, 0x3d2 /* Non-attached container is dirty */);
4345-
}
4293+
/**
4294+
* Emit "dirty" or "saved" event based on the current dirty state of the document.
4295+
* This must be called every time the states underlying the dirty state change.
4296+
*
4297+
* @privateRemarks - It's helpful to think of this as an event handler registered
4298+
* for hypothetical "changed" events for PendingStateManager, Outbox, and Container Attach machinery.
4299+
* But those events don't exist so we manually call this wherever we know those changes happen.
4300+
*/
4301+
private updateDocumentDirtyState(): void {
4302+
const dirty: boolean = this.computeCurrentDirtyState();
43464303

4347-
if (this.dirtyContainer === dirty) {
4304+
if (this.lastEmittedDirty === dirty) {
43484305
return;
43494306
}
43504307

4351-
this.dirtyContainer = dirty;
4308+
this.lastEmittedDirty = dirty;
43524309
if (this.emitDirtyDocumentEvent) {
43534310
this.emit(dirty ? "dirty" : "saved");
43544311
}
@@ -4501,9 +4458,7 @@ export class ContainerRuntime
45014458
throw dpe;
45024459
}
45034460

4504-
if (this.isContainerMessageDirtyable(containerRuntimeMessage)) {
4505-
this.updateDocumentDirtyState(true);
4506-
}
4461+
this.updateDocumentDirtyState();
45074462
}
45084463

45094464
private scheduleFlush(): void {
@@ -4938,3 +4893,36 @@ export function createNewSignalEnvelope(
49384893

49394894
return newEnvelope;
49404895
}
4896+
4897+
export function isContainerMessageDirtyable({
4898+
type,
4899+
contents,
4900+
}: LocalContainerRuntimeMessage): boolean {
4901+
// Certain container runtime messages should not mark the container dirty such as the old built-in
4902+
// AgentScheduler and Garbage collector messages.
4903+
switch (type) {
4904+
case ContainerMessageType.Attach: {
4905+
const attachMessage = contents as InboundAttachMessage;
4906+
if (attachMessage.id === agentSchedulerId) {
4907+
return false;
4908+
}
4909+
break;
4910+
}
4911+
case ContainerMessageType.FluidDataStoreOp: {
4912+
const envelope = contents;
4913+
if (envelope.address === agentSchedulerId) {
4914+
return false;
4915+
}
4916+
break;
4917+
}
4918+
case ContainerMessageType.IdAllocation:
4919+
case ContainerMessageType.DocumentSchemaChange:
4920+
case ContainerMessageType.GC: {
4921+
return false;
4922+
}
4923+
default: {
4924+
break;
4925+
}
4926+
}
4927+
return true;
4928+
}

packages/runtime/container-runtime/src/opLifecycle/batchManager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "@fluidframework/telemetry-utils/internal";
1212

1313
import { ICompressionRuntimeOptions } from "../compressionDefinitions.js";
14+
import { isContainerMessageDirtyable } from "../containerRuntime.js";
1415
import { asBatchMetadata, type IBatchMetadata } from "../metadata.js";
1516
import type { IPendingMessage } from "../pendingStateManager.js";
1617

@@ -168,6 +169,13 @@ export class BatchManager {
168169
},
169170
};
170171
}
172+
173+
/**
174+
* Does this batch current contain user changes ("dirtyable" ops)?
175+
*/
176+
public containsUserChanges(): boolean {
177+
return this.pendingBatch.some((message) => isContainerMessageDirtyable(message.runtimeOp));
178+
}
171179
}
172180

173181
const addBatchMetadata = (batch: LocalBatch, batchId?: BatchId): LocalBatch => {

packages/runtime/container-runtime/src/opLifecycle/outbox.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,13 @@ export class Outbox {
236236
return this.messageCount === 0;
237237
}
238238

239+
public containsUserChanges(): boolean {
240+
return (
241+
this.mainBatch.containsUserChanges() || this.blobAttachBatch.containsUserChanges()
242+
// ID Allocation ops are not user changes
243+
);
244+
}
245+
239246
/**
240247
* Detect whether batching has been interrupted by an incoming message being processed. In this case,
241248
* we will flush the accumulated messages to account for that (if allowed) and create a new batch with the new

packages/runtime/container-runtime/src/pendingStateManager.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import Deque from "double-ended-queue";
1616
import { v4 as uuid } from "uuid";
1717

18+
import { isContainerMessageDirtyable } from "./containerRuntime.js";
1819
import {
1920
type InboundContainerRuntimeMessage,
2021
type InboundSequencedContainerRuntimeMessage,
@@ -282,6 +283,22 @@ export class PendingStateManager implements IDisposable {
282283
return this.pendingMessages.length + this.initialMessages.length;
283284
}
284285

286+
/**
287+
* Checks the pending messages to see if any of them represent user changes (aka "dirtyable" messages)
288+
*/
289+
public hasPendingUserChanges(): boolean {
290+
for (let i = 0; i < this.pendingMessages.length; i++) {
291+
const element = this.pendingMessages.get(i);
292+
// Missing runtimeOp implies not dirtyable: This only happens for empty batches
293+
if (element?.runtimeOp !== undefined && isContainerMessageDirtyable(element.runtimeOp)) {
294+
return true;
295+
}
296+
}
297+
// Consider any initial messages to be user changes
298+
// (it's an approximation since we would have to parse them to know for sure)
299+
return this.initialMessages.length > 0;
300+
}
301+
285302
/**
286303
* The minimumPendingMessageSequenceNumber is the minimum of the first pending message and the first initial message.
287304
*

packages/runtime/container-runtime/src/test/containerRuntime.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,7 @@ describe("Runtime", () => {
970970
return {
971971
replayPendingStates: () => {},
972972
hasPendingMessages: (): boolean => pendingMessages > 0,
973+
hasPendingUserChanges: (): boolean => pendingMessages > 0,
973974
processInboundMessages: (inbound: InboundMessageResult, _local: boolean) => {
974975
const messages =
975976
inbound.type === "fullBatch" ? inbound.messages : [inbound.nextMessage];

0 commit comments

Comments
 (0)