
Jurij Tokarski
Using Firestore Transactions to Handle Race Conditions
When two Cloud Function instances race to create the same external resource, a Firestore transaction decides the winner.
The system creates an OpenAI vector store per company — a dedicated knowledge base the AI secretary queries when answering questions. Creating it is expensive: an API call to Azure OpenAI, followed by writing the returned ID back to the company doc in Firestore.
What I ran into: multiple cloud function instances can process triggers at the same time. If two invocations both check workflow_2_vector_store_id, find it empty, and both proceed to create a vector store — you've just orphaned one. It sits there, billed by the token, never used.
My first instinct was "just check before creating." That doesn't work — the check and the write are not atomic. Two instances read an empty field at the same millisecond, both proceed.
What worked is a Firestore transaction as the gate:
export const patchCompanyWithVectorStoreIfMissing = async (
companyId: string,
vectorStoreId: string,
assistantId: string,
): Promise<{ vectorStoreId: string; wePatched: boolean }> =>
runDBTransaction(async (tx) => {
const snapshot = await tx.get(DOC_COMPANY(companyId));
const existing = snapshot.data()?.workflow_2_vector_store_id?.trim();
if (existing) {
return { vectorStoreId: existing, wePatched: false };
}
tx.update(DOC_COMPANY(companyId), {
workflow_2_vector_store_id: vectorStoreId,
workflow_2_assistant_id: assistantId,
});
return { vectorStoreId, wePatched: true };
});
Both instances still create a vector store optimistically — that's unavoidable, the external API call can't be inside the transaction. But only one wins the write. The loser gets wePatched: false and immediately fires cleanup:
const { vectorStoreId, wePatched } = await patchCompanyWithVectorStoreIfMissing(
company.id,
result.vectorStoreId,
result.assistantId,
);
if (!wePatched) {
llm.cleanupCompanyVectorStoreAndAssistant(result.vectorStoreId, result.assistantId)
.catch(() => {});
}
The transaction is the single source of truth for "has this resource been claimed?" Optimistic creation outside it is fine — as long as the loser always cleans up.
Subscribe to the newsletter:
About Jurij Tokarski
Hey 👋 I'm Jurij. I run Varstatt and create software. Usually, I'm deep in the work shipping for clients or building for myself. Sometimes, I share bits I don't want to forget: mostly about software, products and self-employment.