Skip to content

Commit ca4241e

Browse files
feat: add read-only transactions (#1541)
1 parent b39dd3c commit ca4241e

File tree

6 files changed

+317
-73
lines changed

6 files changed

+317
-73
lines changed

dev/src/index.ts

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
validateMinNumberOfArguments,
6868
validateObject,
6969
validateString,
70+
validateTimestamp,
7071
} from './validate';
7172
import {WriteBatch} from './write-batch';
7273

@@ -929,13 +930,37 @@ export class Firestore implements firestore.Firestore {
929930
*
930931
* @callback Firestore~updateFunction
931932
* @template T
932-
* @param {Transaction} transaction The transaction object for this
933+
* @param {Transaction} transaction The transaction object for this
933934
* transaction.
934935
* @returns {Promise<T>} The promise returned at the end of the transaction.
935936
* This promise will be returned by {@link Firestore#runTransaction} if the
936937
* transaction completed successfully.
937938
*/
938939

940+
/**
941+
* Options object for {@link Firestore#runTransaction} to configure a
942+
* read-only transaction.
943+
*
944+
* @callback Firestore~ReadOnlyTransactionOptions
945+
* @template T
946+
* @param {true} readOnly Set to true to indicate a read-only transaction.
947+
* @param {Timestamp=} readTime If specified, documents are read at the given
948+
* time. This may not be more than 60 seconds in the past from when the
949+
* request is processed by the server.
950+
*/
951+
952+
/**
953+
* Options object for {@link Firestore#runTransaction} to configure a
954+
* read-write transaction.
955+
*
956+
* @callback Firestore~ReadWriteTransactionOptions
957+
* @template T
958+
* @param {false=} readOnly Set to false or omit to indicate a read-write
959+
* transaction.
960+
* @param {number=} maxAttempts The maximum number of attempts for this
961+
* transaction. Defaults to five.
962+
*/
963+
939964
/**
940965
* Executes the given updateFunction and commits the changes applied within
941966
* the transaction.
@@ -944,26 +969,33 @@ export class Firestore implements firestore.Firestore {
944969
* modify Firestore documents under lock. You have to perform all reads before
945970
* before you perform any write.
946971
*
947-
* Documents read during a transaction are locked pessimistically. A
948-
* transaction's lock on a document blocks other transactions, batched
949-
* writes, and other non-transactional writes from changing that document.
950-
* A transaction releases its document locks at commit time or once it times
951-
* out or fails for any reason.
972+
* Transactions can be performed as read-only or read-write transactions. By
973+
* default, transactions are executed in read-write mode.
974+
*
975+
* A read-write transaction obtains a pessimistic lock on all documents that
976+
* are read during the transaction. These locks block other transactions,
977+
* batched writes, and other non-transactional writes from changing that
978+
* document. Any writes in a read-write transactions are committed once
979+
* 'updateFunction' resolves, which also releases all locks.
980+
*
981+
* If a read-write transaction fails with contention, the transaction is
982+
* retried up to five times. The `updateFunction` is invoked once for each
983+
* attempt.
952984
*
953-
* Transactions are committed once 'updateFunction' resolves. If a transaction
954-
* fails with contention, the transaction is retried up to five times. The
955-
* `updateFunction` is invoked once for each attempt.
985+
* Read-only transactions do not lock documents. They can be used to read
986+
* documents at a consistent snapshot in time, which may be up to 60 seconds
987+
* in the past. Read-only transactions are not retried.
956988
*
957989
* Transactions time out after 60 seconds if no documents are read.
958990
* Transactions that are not committed within than 270 seconds are also
959-
* aborted.
991+
* aborted. Any remaining locks are released when a transaction times out.
960992
*
961993
* @template T
962994
* @param {Firestore~updateFunction} updateFunction The user function to
963995
* execute within the transaction context.
964-
* @param {object=} transactionOptions Transaction options.
965-
* @param {number=} transactionOptions.maxAttempts - The maximum number of
966-
* attempts for this transaction.
996+
* @param {
997+
* Firestore~ReadWriteTransactionOptions|Firestore~ReadOnlyTransactionOptions=
998+
* } transactionOptions Transaction options.
967999
* @returns {Promise<T>} If the transaction completed successfully or was
9681000
* explicitly aborted (by the updateFunction returning a failed Promise), the
9691001
* Promise returned by the updateFunction will be returned here. Else if the
@@ -994,28 +1026,55 @@ export class Firestore implements firestore.Firestore {
9941026
*/
9951027
runTransaction<T>(
9961028
updateFunction: (transaction: Transaction) => Promise<T>,
997-
transactionOptions?: {maxAttempts?: number}
1029+
transactionOptions?:
1030+
| firestore.ReadWriteTransactionOptions
1031+
| firestore.ReadOnlyTransactionOptions
9981032
): Promise<T> {
9991033
validateFunction('updateFunction', updateFunction);
10001034

10011035
const tag = requestTag();
10021036

10031037
let maxAttempts = DEFAULT_MAX_TRANSACTION_ATTEMPTS;
1038+
let readOnly = false;
1039+
let readTime: Timestamp | undefined;
10041040

10051041
if (transactionOptions) {
10061042
validateObject('transactionOptions', transactionOptions);
1007-
validateInteger(
1008-
'transactionOptions.maxAttempts',
1009-
transactionOptions.maxAttempts,
1010-
{optional: true, minValue: 1}
1043+
validateBoolean(
1044+
'transactionOptions.readOnly',
1045+
transactionOptions.readOnly,
1046+
{optional: true}
10111047
);
1012-
maxAttempts =
1013-
transactionOptions.maxAttempts || DEFAULT_MAX_TRANSACTION_ATTEMPTS;
1048+
1049+
if (transactionOptions.readOnly) {
1050+
validateTimestamp(
1051+
'transactionOptions.readTime',
1052+
transactionOptions.readTime,
1053+
{optional: true}
1054+
);
1055+
1056+
readOnly = true;
1057+
readTime = transactionOptions.readTime as Timestamp | undefined;
1058+
maxAttempts = 1;
1059+
} else {
1060+
validateInteger(
1061+
'transactionOptions.maxAttempts',
1062+
transactionOptions.maxAttempts,
1063+
{optional: true, minValue: 1}
1064+
);
1065+
1066+
maxAttempts =
1067+
transactionOptions.maxAttempts || DEFAULT_MAX_TRANSACTION_ATTEMPTS;
1068+
}
10141069
}
10151070

10161071
const transaction = new Transaction(this, tag);
10171072
return this.initializeIfNeeded(tag).then(() =>
1018-
transaction.runTransaction(updateFunction, maxAttempts)
1073+
transaction.runTransaction(updateFunction, {
1074+
maxAttempts,
1075+
readOnly,
1076+
readTime,
1077+
})
10191078
);
10201079
}
10211080

dev/src/transaction.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import * as proto from '../protos/firestore_v1_proto_api';
2222
import {ExponentialBackoff} from './backoff';
2323
import {DocumentSnapshot} from './document';
2424
import {Firestore, WriteBatch} from './index';
25+
import {Timestamp} from './timestamp';
2526
import {logger} from './logger';
2627
import {FieldPath, validateFieldPath} from './path';
2728
import {StatusCode} from './status-code';
@@ -346,12 +347,18 @@ export class Transaction implements firestore.Transaction {
346347
*
347348
* @private
348349
*/
349-
begin(): Promise<void> {
350+
begin(readOnly: boolean, readTime: Timestamp | undefined): Promise<void> {
350351
const request: api.IBeginTransactionRequest = {
351352
database: this._firestore.formattedName,
352353
};
353354

354-
if (this._transactionId) {
355+
if (readOnly) {
356+
request.options = {
357+
readOnly: {
358+
readTime: readTime?.toProto()?.timestampValue,
359+
},
360+
};
361+
} else if (this._transactionId) {
355362
request.options = {
356363
readWrite: {
357364
retryTransaction: this._transactionId,
@@ -406,16 +413,20 @@ export class Transaction implements firestore.Transaction {
406413
* context.
407414
* @param requestTag A unique client-assigned identifier for the scope of
408415
* this transaction.
409-
* @param maxAttempts The maximum number of attempts for this transaction.
416+
* @param options The user-defined options for this transaction.
410417
*/
411418
async runTransaction<T>(
412419
updateFunction: (transaction: Transaction) => Promise<T>,
413-
maxAttempts: number
420+
options: {
421+
maxAttempts: number;
422+
readOnly: boolean;
423+
readTime?: Timestamp;
424+
}
414425
): Promise<T> {
415426
let result: T;
416427
let lastError: GoogleError | undefined = undefined;
417428

418-
for (let attempt = 0; attempt < maxAttempts; ++attempt) {
429+
for (let attempt = 0; attempt < options.maxAttempts; ++attempt) {
419430
try {
420431
if (lastError) {
421432
logger(
@@ -430,7 +441,7 @@ export class Transaction implements firestore.Transaction {
430441
this._writeBatch._reset();
431442
await this.maybeBackoff(lastError);
432443

433-
await this.begin();
444+
await this.begin(options.readOnly, options.readTime);
434445

435446
const promise = updateFunction(this);
436447
if (!(promise instanceof Promise)) {

dev/src/validate.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import {URL} from 'url';
1818
import {FieldPath} from './path';
1919
import {isFunction, isObject} from './util';
20+
import {Timestamp} from './timestamp';
2021

2122
/**
2223
* Options to allow argument omission.
@@ -278,6 +279,26 @@ export function validateInteger(
278279
}
279280
}
280281

282+
/**
283+
* Validates that 'value' is a Timestamp.
284+
*
285+
* @private
286+
* @param arg The argument name or argument index (for varargs methods).
287+
* @param value The input to validate.
288+
* @param options Options that specify whether the Timestamp can be omitted.
289+
*/
290+
export function validateTimestamp(
291+
arg: string | number,
292+
value: unknown,
293+
options?: RequiredArgumentOptions
294+
): void {
295+
if (!validateOptional(value, options)) {
296+
if (!(value instanceof Timestamp)) {
297+
throw new Error(invalidArgumentMessage(arg, 'Timestamp'));
298+
}
299+
}
300+
}
301+
281302
/**
282303
* Generates an error message to use with invalid arguments.
283304
*

dev/system-test/firestore.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2345,6 +2345,47 @@ describe('Transaction class', () => {
23452345
const finalSnapshot = await ref.get();
23462346
expect(finalSnapshot.data()).to.deep.equal({first: true, second: true});
23472347
});
2348+
2349+
it('supports read-only transactions', async () => {
2350+
const ref = randomCol.doc('doc');
2351+
await ref.set({foo: 'bar'});
2352+
const snapshot = await firestore.runTransaction(
2353+
updateFunction => updateFunction.get(ref),
2354+
{readOnly: true}
2355+
);
2356+
expect(snapshot.exists).to.be.true;
2357+
});
2358+
2359+
it('supports read-only transactions with custom read-time', async () => {
2360+
const ref = randomCol.doc('doc');
2361+
const writeResult = await ref.set({foo: 1});
2362+
await ref.set({foo: 2});
2363+
const snapshot = await firestore.runTransaction(
2364+
updateFunction => updateFunction.get(ref),
2365+
{readOnly: true, readTime: writeResult.writeTime}
2366+
);
2367+
expect(snapshot.exists).to.be.true;
2368+
expect(snapshot.get('foo')).to.equal(1);
2369+
});
2370+
2371+
it('fails read-only with writes', async () => {
2372+
let attempts = 0;
2373+
2374+
const ref = randomCol.doc('doc');
2375+
try {
2376+
await firestore.runTransaction(
2377+
async updateFunction => {
2378+
++attempts;
2379+
updateFunction.set(ref, {});
2380+
},
2381+
{readOnly: true}
2382+
);
2383+
expect.fail();
2384+
} catch (e) {
2385+
expect(attempts).to.equal(1);
2386+
expect(e.code).to.equal(Status.INVALID_ARGUMENT);
2387+
}
2388+
});
23482389
});
23492390

23502391
describe('WriteBatch class', () => {

0 commit comments

Comments
 (0)