/*! * Copyright (c) Microsoft Corporation and contributors. All rights reserved. * Licensed under the MIT License. */ import { TypedEventEmitter } from "@fluid-internal/client-utils"; import type { IEvent, IEventProvider } from "@fluidframework/core-interfaces"; import type { IClientDetails } from "@fluidframework/driver-definitions"; import { MessageType } from "@fluidframework/driver-definitions/internal"; import type { ITelemetryLoggerExt } from "@fluidframework/telemetry-utils/internal"; import type { IOrderedClientElection, ISerializedElection, ITrackedClient, } from "./orderedClientElection.js"; import { summarizerClientType } from "./summarizerTypes.js"; import type { ISummaryCollectionOpEvents } from "./summaryCollection.js"; export interface ISummarizerClientElectionEvents extends IEvent { (event: "electedSummarizerChanged", handler: () => void): void; } export interface ISummarizerClientElection extends IEventProvider { readonly electedClientId: string | undefined; readonly electedParentId: string | undefined; } /** * This class encapsulates logic around tracking the elected summarizer client. * It will handle updating the elected client when a summary ack hasn't been seen * for some configured number of ops. */ export class SummarizerClientElection extends TypedEventEmitter implements ISummarizerClientElection { /** * Used to calculate number of ops since last summary ack for the current elected client. * This will be undefined if there is no elected summarizer, or no summary ack has been * observed since this client was elected. * When a summary ack comes in, this will be set to the sequence number of the summary ack. */ private lastSummaryAckSeqForClient: number | undefined; /** * Used to prevent excess logging by recording the sequence number that we last reported at, * and making sure we don't report another event to telemetry. If things work as intended, * this is not needed, otherwise it could report an event on every op in worst case scenario. */ private lastReportedSeq = 0; public get electedClientId(): string | undefined { return this.clientElection.electedClient?.clientId; } public get electedParentId(): string | undefined { return this.clientElection.electedParent?.clientId; } constructor( private readonly logger: ITelemetryLoggerExt, private readonly summaryCollection: IEventProvider, public readonly clientElection: IOrderedClientElection, private readonly maxOpsSinceLastSummary: number, ) { super(); // On every inbound op, if enough ops pass without seeing a summary ack (per elected client), // elect a new client and log to telemetry. this.summaryCollection.on("default", ({ sequenceNumber }) => { const electedClientId = this.electedClientId; if (electedClientId === undefined) { // Reset election if no elected client, but eligible clients are connected. // This should be uncommon, but is possible if the initial state of the // ordered client election contains an undefined client id or one not found // in the quorum (the latter would already log an error). if (this.clientElection.eligibleCount > 0) { this.clientElection.resetElectedClient(sequenceNumber); } return; } const electionSequenceNumber = this.clientElection.electionSequenceNumber; const opsWithoutSummary = sequenceNumber - (this.lastSummaryAckSeqForClient ?? electionSequenceNumber); if (opsWithoutSummary > this.maxOpsSinceLastSummary) { // Log and elect a new summarizer client. const opsSinceLastReport = sequenceNumber - this.lastReportedSeq; if (opsSinceLastReport > this.maxOpsSinceLastSummary) { this.logger.sendTelemetryEvent({ eventName: "ElectedClientNotSummarizing", electedClientId, lastSummaryAckSeqForClient: this.lastSummaryAckSeqForClient, electionSequenceNumber, nextElectedClientId: this.clientElection.peekNextElectedClient()?.clientId, opsWithoutSummary, }); this.lastReportedSeq = sequenceNumber; } } }); // When a summary ack comes in, reset our op seq counter. this.summaryCollection.on(MessageType.SummaryAck, (op) => { this.lastSummaryAckSeqForClient = op.sequenceNumber; }); // Use oldest client election for unanimously and deterministically deciding // which client should summarize. this.clientElection.on("election", (client, sequenceNumber) => { this.lastSummaryAckSeqForClient = undefined; if (client === undefined && this.clientElection.eligibleCount > 0) { // If no client is valid for election, reset to the oldest again. // Also make extra sure not to get stuck in an infinite loop here: // If there are no eligible clients, just wait until a client joins // and will be auto-elected. this.clientElection.resetElectedClient(sequenceNumber); } // Election can trigger a change in SummaryManager state. this.emit("electedSummarizerChanged"); }); } public serialize(): ISerializedElection { const { electedClientId, electedParentId, electionSequenceNumber } = this.clientElection.serialize(); return { electedClientId, electedParentId, electionSequenceNumber: this.lastSummaryAckSeqForClient ?? electionSequenceNumber, }; } public static isClientEligible(client: ITrackedClient): boolean { const details = client.client.details; if (details === undefined) { // Very old clients back-compat return true; } return SummarizerClientElection.clientDetailsPermitElection(details); } public static readonly clientDetailsPermitElection = (details: IClientDetails): boolean => details.capabilities.interactive || details.type === summarizerClientType; }