Skip to content

Commit

Permalink
feat: add read-only transactions (#1541)
Browse files Browse the repository at this point in the history
  • Loading branch information
schmidt-sebastian authored Jun 29, 2021
1 parent b39dd3c commit ca4241e
Show file tree
Hide file tree
Showing 6 changed files with 317 additions and 73 deletions.
101 changes: 80 additions & 21 deletions dev/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
validateMinNumberOfArguments,
validateObject,
validateString,
validateTimestamp,
} from './validate';
import {WriteBatch} from './write-batch';

Expand Down Expand Up @@ -929,13 +930,37 @@ export class Firestore implements firestore.Firestore {
*
* @callback Firestore~updateFunction
* @template T
* @param {Transaction} transaction The transaction object for this
* @param {Transaction} transaction The transaction object for this
* transaction.
* @returns {Promise<T>} The promise returned at the end of the transaction.
* This promise will be returned by {@link Firestore#runTransaction} if the
* transaction completed successfully.
*/

/**
* Options object for {@link Firestore#runTransaction} to configure a
* read-only transaction.
*
* @callback Firestore~ReadOnlyTransactionOptions
* @template T
* @param {true} readOnly Set to true to indicate a read-only transaction.
* @param {Timestamp=} readTime If specified, documents are read at the given
* time. This may not be more than 60 seconds in the past from when the
* request is processed by the server.
*/

/**
* Options object for {@link Firestore#runTransaction} to configure a
* read-write transaction.
*
* @callback Firestore~ReadWriteTransactionOptions
* @template T
* @param {false=} readOnly Set to false or omit to indicate a read-write
* transaction.
* @param {number=} maxAttempts The maximum number of attempts for this
* transaction. Defaults to five.
*/

/**
* Executes the given updateFunction and commits the changes applied within
* the transaction.
Expand All @@ -944,26 +969,33 @@ export class Firestore implements firestore.Firestore {
* modify Firestore documents under lock. You have to perform all reads before
* before you perform any write.
*
* Documents read during a transaction are locked pessimistically. A
* transaction's lock on a document blocks other transactions, batched
* writes, and other non-transactional writes from changing that document.
* A transaction releases its document locks at commit time or once it times
* out or fails for any reason.
* Transactions can be performed as read-only or read-write transactions. By
* default, transactions are executed in read-write mode.
*
* A read-write transaction obtains a pessimistic lock on all documents that
* are read during the transaction. These locks block other transactions,
* batched writes, and other non-transactional writes from changing that
* document. Any writes in a read-write transactions are committed once
* 'updateFunction' resolves, which also releases all locks.
*
* If a read-write transaction fails with contention, the transaction is
* retried up to five times. The `updateFunction` is invoked once for each
* attempt.
*
* Transactions are committed once 'updateFunction' resolves. If a transaction
* fails with contention, the transaction is retried up to five times. The
* `updateFunction` is invoked once for each attempt.
* Read-only transactions do not lock documents. They can be used to read
* documents at a consistent snapshot in time, which may be up to 60 seconds
* in the past. Read-only transactions are not retried.
*
* Transactions time out after 60 seconds if no documents are read.
* Transactions that are not committed within than 270 seconds are also
* aborted.
* aborted. Any remaining locks are released when a transaction times out.
*
* @template T
* @param {Firestore~updateFunction} updateFunction The user function to
* execute within the transaction context.
* @param {object=} transactionOptions Transaction options.
* @param {number=} transactionOptions.maxAttempts - The maximum number of
* attempts for this transaction.
* @param {
* Firestore~ReadWriteTransactionOptions|Firestore~ReadOnlyTransactionOptions=
* } transactionOptions Transaction options.
* @returns {Promise<T>} If the transaction completed successfully or was
* explicitly aborted (by the updateFunction returning a failed Promise), the
* Promise returned by the updateFunction will be returned here. Else if the
Expand Down Expand Up @@ -994,28 +1026,55 @@ export class Firestore implements firestore.Firestore {
*/
runTransaction<T>(
updateFunction: (transaction: Transaction) => Promise<T>,
transactionOptions?: {maxAttempts?: number}
transactionOptions?:
| firestore.ReadWriteTransactionOptions
| firestore.ReadOnlyTransactionOptions
): Promise<T> {
validateFunction('updateFunction', updateFunction);

const tag = requestTag();

let maxAttempts = DEFAULT_MAX_TRANSACTION_ATTEMPTS;
let readOnly = false;
let readTime: Timestamp | undefined;

if (transactionOptions) {
validateObject('transactionOptions', transactionOptions);
validateInteger(
'transactionOptions.maxAttempts',
transactionOptions.maxAttempts,
{optional: true, minValue: 1}
validateBoolean(
'transactionOptions.readOnly',
transactionOptions.readOnly,
{optional: true}
);
maxAttempts =
transactionOptions.maxAttempts || DEFAULT_MAX_TRANSACTION_ATTEMPTS;

if (transactionOptions.readOnly) {
validateTimestamp(
'transactionOptions.readTime',
transactionOptions.readTime,
{optional: true}
);

readOnly = true;
readTime = transactionOptions.readTime as Timestamp | undefined;
maxAttempts = 1;
} else {
validateInteger(
'transactionOptions.maxAttempts',
transactionOptions.maxAttempts,
{optional: true, minValue: 1}
);

maxAttempts =
transactionOptions.maxAttempts || DEFAULT_MAX_TRANSACTION_ATTEMPTS;
}
}

const transaction = new Transaction(this, tag);
return this.initializeIfNeeded(tag).then(() =>
transaction.runTransaction(updateFunction, maxAttempts)
transaction.runTransaction(updateFunction, {
maxAttempts,
readOnly,
readTime,
})
);
}

Expand Down
23 changes: 17 additions & 6 deletions dev/src/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import * as proto from '../protos/firestore_v1_proto_api';
import {ExponentialBackoff} from './backoff';
import {DocumentSnapshot} from './document';
import {Firestore, WriteBatch} from './index';
import {Timestamp} from './timestamp';
import {logger} from './logger';
import {FieldPath, validateFieldPath} from './path';
import {StatusCode} from './status-code';
Expand Down Expand Up @@ -346,12 +347,18 @@ export class Transaction implements firestore.Transaction {
*
* @private
*/
begin(): Promise<void> {
begin(readOnly: boolean, readTime: Timestamp | undefined): Promise<void> {
const request: api.IBeginTransactionRequest = {
database: this._firestore.formattedName,
};

if (this._transactionId) {
if (readOnly) {
request.options = {
readOnly: {
readTime: readTime?.toProto()?.timestampValue,
},
};
} else if (this._transactionId) {
request.options = {
readWrite: {
retryTransaction: this._transactionId,
Expand Down Expand Up @@ -406,16 +413,20 @@ export class Transaction implements firestore.Transaction {
* context.
* @param requestTag A unique client-assigned identifier for the scope of
* this transaction.
* @param maxAttempts The maximum number of attempts for this transaction.
* @param options The user-defined options for this transaction.
*/
async runTransaction<T>(
updateFunction: (transaction: Transaction) => Promise<T>,
maxAttempts: number
options: {
maxAttempts: number;
readOnly: boolean;
readTime?: Timestamp;
}
): Promise<T> {
let result: T;
let lastError: GoogleError | undefined = undefined;

for (let attempt = 0; attempt < maxAttempts; ++attempt) {
for (let attempt = 0; attempt < options.maxAttempts; ++attempt) {
try {
if (lastError) {
logger(
Expand All @@ -430,7 +441,7 @@ export class Transaction implements firestore.Transaction {
this._writeBatch._reset();
await this.maybeBackoff(lastError);

await this.begin();
await this.begin(options.readOnly, options.readTime);

const promise = updateFunction(this);
if (!(promise instanceof Promise)) {
Expand Down
21 changes: 21 additions & 0 deletions dev/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import {URL} from 'url';
import {FieldPath} from './path';
import {isFunction, isObject} from './util';
import {Timestamp} from './timestamp';

/**
* Options to allow argument omission.
Expand Down Expand Up @@ -278,6 +279,26 @@ export function validateInteger(
}
}

/**
* Validates that 'value' is a Timestamp.
*
* @private
* @param arg The argument name or argument index (for varargs methods).
* @param value The input to validate.
* @param options Options that specify whether the Timestamp can be omitted.
*/
export function validateTimestamp(
arg: string | number,
value: unknown,
options?: RequiredArgumentOptions
): void {
if (!validateOptional(value, options)) {
if (!(value instanceof Timestamp)) {
throw new Error(invalidArgumentMessage(arg, 'Timestamp'));
}
}
}

/**
* Generates an error message to use with invalid arguments.
*
Expand Down
41 changes: 41 additions & 0 deletions dev/system-test/firestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,47 @@ describe('Transaction class', () => {
const finalSnapshot = await ref.get();
expect(finalSnapshot.data()).to.deep.equal({first: true, second: true});
});

it('supports read-only transactions', async () => {
const ref = randomCol.doc('doc');
await ref.set({foo: 'bar'});
const snapshot = await firestore.runTransaction(
updateFunction => updateFunction.get(ref),
{readOnly: true}
);
expect(snapshot.exists).to.be.true;
});

it('supports read-only transactions with custom read-time', async () => {
const ref = randomCol.doc('doc');
const writeResult = await ref.set({foo: 1});
await ref.set({foo: 2});
const snapshot = await firestore.runTransaction(
updateFunction => updateFunction.get(ref),
{readOnly: true, readTime: writeResult.writeTime}
);
expect(snapshot.exists).to.be.true;
expect(snapshot.get('foo')).to.equal(1);
});

it('fails read-only with writes', async () => {
let attempts = 0;

const ref = randomCol.doc('doc');
try {
await firestore.runTransaction(
async updateFunction => {
++attempts;
updateFunction.set(ref, {});
},
{readOnly: true}
);
expect.fail();
} catch (e) {
expect(attempts).to.equal(1);
expect(e.code).to.equal(Status.INVALID_ARGUMENT);
}
});
});

describe('WriteBatch class', () => {
Expand Down
Loading

0 comments on commit ca4241e

Please sign in to comment.