convex
Version:
Client for the Convex Cloud
169 lines (149 loc) • 5.38 kB
text/typescript
import { jsonToConvex } from "../../values/index.js";
import { createError, logToConsole } from "../logging.js";
import { Long } from "../long.js";
import {
ClientMessage,
MutationRequest,
MutationResponse,
RequestId,
} from "./protocol.js";
type RequestStatus =
| {
status: "Requested";
onResult: (result: any) => void;
onFailure: (reason: any) => void;
}
| {
status: "Completed";
onResolve: () => void;
ts: Long;
};
// RequestManager tracks idempotent requests - Mutations.
export class RequestManager {
private inflightRequests: Map<
RequestId,
{
message: MutationRequest;
status: RequestStatus;
}
>;
constructor() {
this.inflightRequests = new Map();
}
request(message: MutationRequest): Promise<any> {
const result = new Promise((resolve, reject) => {
this.inflightRequests.set(message.requestId, {
message,
status: { status: "Requested", onResult: resolve, onFailure: reject },
});
});
return result;
}
/**
* Update the state after receiving a response.
*
* @returns A RequestId if the request is complete and its optimistic update
* can be dropped, null otherwise. Only applies to mutations.
*/
onResponse(response: MutationResponse): RequestId | null {
const requestInfo = this.inflightRequests.get(response.requestId);
if (requestInfo === undefined) {
// Annoyingly we can occasionally get responses to mutations that we're no
// longer tracking. One flow where this happens is:
// 1. Client sends mutation 1
// 2. Client gets response for mutation 1. The sever says that it was committed at ts=10.
// 3. Client is disconnected
// 4. Client reconnects and re-issues queries and this mutation.
// 5. Server sends transition message to ts=20
// 6. Client drops mutation because it's already been observed.
// 7. Client receives a second response for mutation 1 but doesn't know about it anymore.
// The right fix for this is probably to add a reconciliation phase on
// reconnection where we receive responses to all the mutations before
// the transition message so this flow could never happen (CX-1513).
// For now though, we can just ignore this message.
return null;
}
// Because `.restart()` re-requests completed requests, we may get some
// responses for requests that are already in the "Completed" state.
// We can safely ignore those because we've already notified the UI about
// their results.
if (requestInfo.status.status !== "Requested") {
return null;
}
const udfType = "mutation";
const udfPath = requestInfo.message.udfPath;
for (const line of response.logLines) {
logToConsole("info", udfType, udfPath, line);
}
const status = requestInfo.status;
let onResolve;
if (response.success) {
onResolve = () => status.onResult(jsonToConvex(response.result));
} else {
logToConsole("error", udfType, udfPath, response.result);
onResolve = () =>
status.onFailure(createError(udfType, udfPath, response.result));
}
if (!response.success) {
// We can resolve Mutation failures immediately since they don't have any
// side effects.
onResolve();
this.inflightRequests.delete(response.requestId);
return response.requestId;
}
// We have to wait to resolve the request promise until after we transition
// past this timestamp so clients can read their own writes.
requestInfo.status = {
status: "Completed",
ts: response.ts,
onResolve,
};
return null;
}
// Removed completed requests and returns the set of completed
// mutations.
removeCompleted(ts: Long): Set<RequestId> {
const completeMutations: Set<RequestId> = new Set();
for (const [requestId, requestInfo] of this.inflightRequests.entries()) {
const status = requestInfo.status;
if (status.status === "Completed" && status.ts.lessThanOrEqual(ts)) {
status.onResolve();
if (requestInfo.message.type === "Mutation") {
completeMutations.add(requestId);
}
this.inflightRequests.delete(requestId);
}
}
return completeMutations;
}
restart(): ClientMessage[] {
// When we reconnect to the backend, re-request all the in-flight requests.
// This includes ones that have already been completed because we still
// want to tell the backend to transition the client past the completed
// timestamp. This is safe because mutations are idempotent.
const allMessages = [];
for (const value of this.inflightRequests.values()) {
allMessages.push(value.message);
}
return allMessages;
}
/**
** @returns true if there are any requests that have been requested but have
** not be completed yet.
**/
hasIncompleteRequests(): boolean {
for (const requestInfo of this.inflightRequests.values()) {
if (requestInfo.status.status === "Requested") {
return true;
}
}
return false;
}
/**
** @returns true if there are any inflight requests, including ones that have
** completed on the server, but have not been applied.
**/
hasInflightRequests(): boolean {
return this.inflightRequests.size > 0;
}
}