Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add read-only transactions #1541

Merged
merged 7 commits into from
Jun 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: specify that read-only transactions not retried in the documentation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (might make sense for you to do a quick sanity check on the result)

} 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 () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: verify that the tx was not retried.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Sebastian goes above and beyond"

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