diff --git a/packages/firestore/src/core/firestore_client.ts b/packages/firestore/src/core/firestore_client.ts index a344a7546ed..fe65f132b89 100644 --- a/packages/firestore/src/core/firestore_client.ts +++ b/packages/firestore/src/core/firestore_client.ts @@ -241,7 +241,12 @@ export class FirestoreClient { const serializer = new JsonProtoSerializer(this.databaseInfo.databaseId, { useProto3Json: true }); - this.persistence = new IndexedDbPersistence(storagePrefix, serializer); + this.persistence = new IndexedDbPersistence( + storagePrefix, + this.platform, + this.asyncQueue, + serializer + ); return this.persistence.start(); } @@ -252,7 +257,7 @@ export class FirestoreClient { */ private startMemoryPersistence(): Promise { this.garbageCollector = new EagerGarbageCollector(); - this.persistence = new MemoryPersistence(); + this.persistence = new MemoryPersistence(this.asyncQueue); return this.persistence.start(); } @@ -309,6 +314,13 @@ export class FirestoreClient { }) .then(() => { return this.remoteStore.start(); + }) + .then(() => { + // NOTE: This will immediately call the listener, so we make sure to + // set it after localStore / remoteStore are started. + this.persistence.setPrimaryStateListener(isPrimary => + this.syncEngine.applyPrimaryState(isPrimary) + ); }); } diff --git a/packages/firestore/src/core/sync_engine.ts b/packages/firestore/src/core/sync_engine.ts index 7170d1ed5d6..0b43e6f79f9 100644 --- a/packages/firestore/src/core/sync_engine.ts +++ b/packages/firestore/src/core/sync_engine.ts @@ -121,6 +121,7 @@ export class SyncEngine implements RemoteSyncer { [uidKey: string]: SortedMap>; }; private targetIdGenerator = TargetIdGenerator.forSyncEngine(); + private isPrimary = false; constructor( private localStore: LocalStore, @@ -128,6 +129,11 @@ export class SyncEngine implements RemoteSyncer { private currentUser: User ) {} + // Only used for testing. + get isPrimaryClient() { + return this.isPrimary; + } + /** Subscribes view and error handler. Can be called only once. */ subscribe(viewHandler: ViewHandler, errorHandler: ErrorHandler): void { assert( @@ -616,4 +622,9 @@ export class SyncEngine implements RemoteSyncer { return this.remoteStore.handleUserChange(user); }); } + + applyPrimaryState(isPrimary: boolean): Promise { + this.isPrimary = isPrimary; + return Promise.resolve(); + } } diff --git a/packages/firestore/src/local/indexeddb_persistence.ts b/packages/firestore/src/local/indexeddb_persistence.ts index b09548e5e2b..b6368ab96d3 100644 --- a/packages/firestore/src/local/indexeddb_persistence.ts +++ b/packages/firestore/src/local/indexeddb_persistence.ts @@ -28,32 +28,50 @@ import { IndexedDbRemoteDocumentCache } from './indexeddb_remote_document_cache' import { ALL_STORES, createOrUpgradeDb, + DbClientMetadataKey, + DbClientMetadata, DbOwner, DbOwnerKey, SCHEMA_VERSION } from './indexeddb_schema'; import { LocalSerializer } from './local_serializer'; import { MutationQueue } from './mutation_queue'; -import { Persistence } from './persistence'; +import { + Persistence, + PersistenceTransaction, + PrimaryStateListener +} from './persistence'; import { PersistencePromise } from './persistence_promise'; import { QueryCache } from './query_cache'; import { RemoteDocumentCache } from './remote_document_cache'; -import { SimpleDb, SimpleDbTransaction } from './simple_db'; +import { SimpleDb, SimpleDbStore, SimpleDbTransaction } from './simple_db'; +import { Platform } from '../platform/platform'; +import { AsyncQueue, TimerId } from '../util/async_queue'; +import { ClientKey } from './shared_client_state'; const LOG_TAG = 'IndexedDbPersistence'; -/** If the owner lease is older than 5 seconds, try to take ownership. */ -const OWNER_LEASE_MAX_AGE_MS = 5000; -/** Refresh the owner lease every 4 seconds while owner. */ -const OWNER_LEASE_REFRESH_INTERVAL_MS = 4000; - -/** LocalStorage location to indicate a zombied ownerId (see class comment). */ -const ZOMBIE_OWNER_LOCALSTORAGE_SUFFIX = 'zombiedOwnerId'; -/** Error when the owner lease cannot be acquired or is lost. */ -const EXISTING_OWNER_ERROR_MSG = - 'There is another tab open with offline' + - ' persistence enabled. Only one such tab is allowed at a time. The' + - ' other tab must be closed or persistence must be disabled.'; +/** + * Oldest acceptable age in milliseconds for client metadata read from + * IndexedDB. Client metadata and primary leases that are older than 5 seconds + * are ignored. + */ +const CLIENT_METADATA_MAX_AGE_MS = 5000; +/** + * The interval at which clients will update their metadata, including + * refreshing their primary lease if held or potentially trying to acquire it if + * not held. + * + * Primary clients may opportunistically refresh their metadata earlier + * if they're already performing an IndexedDB operation. + */ +const CLIENT_METADATA_REFRESH_INTERVAL_MS = 4000; +/** LocalStorage location to indicate a zombied client id (see class comment). */ +const ZOMBIED_PRIMARY_LOCALSTORAGE_SUFFIX = 'zombiedClientId'; +/** User-facing error when the primary lease is required but not available. */ +const PRIMARY_LEASE_LOST_ERROR_MSG = + 'The current tab is not in the required state to perform this operation. ' + + 'It might be necessary to refresh the browser tab.'; const UNSUPPORTED_PLATFORM_ERROR_MSG = 'This platform is either missing' + ' IndexedDB or is known to have an incomplete implementation. Offline' + @@ -84,9 +102,11 @@ const UNSUPPORTED_PLATFORM_ERROR_MSG = * a refreshed tab is able to immediately re-acquire the owner lease). * Unfortunately, IndexedDB cannot be reliably used in window.unload since it is * an asynchronous API. So in addition to attempting to give up the lease, - * the owner writes its ownerId to a "zombiedOwnerId" entry in LocalStorage + * the owner writes its ownerId to a "zombiedClientId" entry in LocalStorage * which acts as an indicator that another tab should go ahead and take the * owner lease immediately regardless of the current lease timestamp. + * + * TODO(multitab): Update this comment with multi-tab changes. */ export class IndexedDbPersistence implements Persistence { /** @@ -95,29 +115,44 @@ export class IndexedDbPersistence implements Persistence { */ static MAIN_DATABASE = 'main'; + private readonly document: Document; + private readonly window: Window; + private simpleDb: SimpleDb; private started: boolean; + private isPrimary = false; private dbName: string; private localStoragePrefix: string; - private ownerId: string = this.generateOwnerId(); + private readonly clientKey = this.generateClientKey(); /** * Set to an Error object if we encounter an unrecoverable error. All further * transactions will be failed with this error. */ private persistenceError: Error | null; - /** The setInterval() handle tied to refreshing the owner lease. */ - // tslint:disable-next-line:no-any setTimeout() type differs on browser / node - private ownerLeaseRefreshHandle: any; /** Our window.unload handler, if registered. */ private windowUnloadHandler: (() => void) | null; + private inForeground = false; private serializer: LocalSerializer; - constructor(prefix: string, serializer: JsonProtoSerializer) { + /** Our 'visibilitychange' listener if registered. */ + private documentVisibilityHandler: ((e?: Event) => void) | null; + + /** A listener to notify on primary state changes. */ + private primaryStateListener: PrimaryStateListener = _ => Promise.resolve(); + + constructor( + prefix: string, + platform: Platform, + private readonly queue: AsyncQueue, + serializer: JsonProtoSerializer + ) { this.dbName = prefix + IndexedDbPersistence.MAIN_DATABASE; this.serializer = new LocalSerializer(serializer); this.localStoragePrefix = prefix; + this.document = platform.document; + this.window = platform.window; } start(): Promise { @@ -132,23 +167,143 @@ export class IndexedDbPersistence implements Persistence { assert(!this.started, 'IndexedDbPersistence double-started!'); this.started = true; + assert( + this.window !== null && this.document !== null, + "Expected 'window' and 'document' to be defined" + ); + return SimpleDb.openOrCreate(this.dbName, SCHEMA_VERSION, createOrUpgradeDb) .then(db => { this.simpleDb = db; }) - .then(() => this.tryAcquireOwnerLease()) .then(() => { - this.scheduleOwnerLeaseRefreshes(); + this.attachVisibilityHandler(); this.attachWindowUnloadHook(); + return this.updateClientMetadataAndTryBecomePrimary().then(() => + this.scheduleClientMetadataAndPrimaryLeaseRefreshes() + ); + }); + } + + setPrimaryStateListener(primaryStateListener: PrimaryStateListener) { + this.primaryStateListener = primaryStateListener; + primaryStateListener(this.isPrimary); + } + + /** + * Updates the client metadata in IndexedDb and attempts to either obtain or + * extend the primary lease for the local client. Asynchronously notifies the + * primary state listener if the client either newly obtained or released its + * primary lease. + */ + private updateClientMetadataAndTryBecomePrimary(): Promise { + return this.simpleDb.runTransaction('readwrite', ALL_STORES, txn => { + const metadataStore = clientMetadataStore(txn); + metadataStore.put( + new DbClientMetadata(this.clientKey, Date.now(), this.inForeground) + ); + + return this.canActAsPrimary(txn).next(canActAsPrimary => { + if (canActAsPrimary !== this.isPrimary) { + this.isPrimary = canActAsPrimary; + this.queue.enqueue(() => this.primaryStateListener(this.isPrimary)); + } + + if (this.isPrimary) { + return this.acquireOrExtendPrimaryLease(txn); + } + }); + }); + } + + /** + * Schedules a recurring timer to update the client metadata and to either + * extend or acquire the primary lease if the client is eligible. + */ + private scheduleClientMetadataAndPrimaryLeaseRefreshes(): void { + this.queue.enqueueAfterDelay( + TimerId.ClientMetadataRefresh, + CLIENT_METADATA_REFRESH_INTERVAL_MS, + () => { + return this.updateClientMetadataAndTryBecomePrimary().then(() => + this.scheduleClientMetadataAndPrimaryLeaseRefreshes() + ); + } + ); + } + + /** Checks whether `client` is the local client. */ + private isLocalClient(client: DbOwner | null): boolean { + return client ? client.ownerId === this.clientKey : false; + } + + /** + * Evaluate the state of all active clients and determine whether the local + * client is or can act as the holder of the primary lease. Returns whether + * the client is eligible for the lease, but does not actually acquire it. + * May return 'false' even if there is no active leaseholder and another + * (foreground) client should become leaseholder instead. + */ + private canActAsPrimary( + txn: SimpleDbTransaction + ): PersistencePromise { + const store = ownerStore(txn); + return store + .get('owner') + .next(currentPrimary => { + const currentLeaseIsValid = + currentPrimary !== null && + this.isWithinMaxAge(currentPrimary.leaseTimestampMs) && + currentPrimary.ownerId !== this.getZombiedClientId(); + + if (currentLeaseIsValid) { + return this.isLocalClient(currentPrimary); + } + + // Check if this client is eligible for a primary lease based on its + // visibility state and the visibility state of all active clients. A + // client can obtain the primary lease if it is either in the foreground + // or if this client and all other clients are in the background. + if (this.inForeground) { + return true; + } + + let canActAsPrimary = true; + return clientMetadataStore(txn) + .iterate((key, value, control) => { + if (this.clientKey !== value.clientKey) { + if ( + this.isWithinMaxAge(value.updateTimeMs) && + value.inForeground + ) { + canActAsPrimary = false; + control.done(); + } + } + }) + .next(() => canActAsPrimary); + }) + .next(canActAsPrimary => { + if (this.isPrimary !== canActAsPrimary) { + log.debug( + LOG_TAG, + `Client ${ + canActAsPrimary ? 'is' : 'is not' + } eligible for a primary lease.` + ); + } + return canActAsPrimary; }); } shutdown(): Promise { - assert(this.started, 'IndexedDbPersistence shutdown without start!'); + if (!this.started) { + return Promise.resolve(); + } this.started = false; + this.detachVisibilityHandler(); this.detachWindowUnloadHook(); - this.stopOwnerLeaseRefreshes(); - return this.releaseOwnerLease().then(() => { + return this.releasePrimaryLeaseIfHeld().then(() => { this.simpleDb.close(); }); } @@ -167,7 +322,10 @@ export class IndexedDbPersistence implements Persistence { runTransaction( action: string, - operation: (transaction: SimpleDbTransaction) => PersistencePromise + requirePrimaryLease: boolean, + transactionOperation: ( + transaction: PersistenceTransaction + ) => PersistencePromise ): Promise { if (this.persistenceError) { return Promise.reject(this.persistenceError); @@ -178,11 +336,42 @@ export class IndexedDbPersistence implements Persistence { // Do all transactions as readwrite against all object stores, since we // are the only reader/writer. return this.simpleDb.runTransaction('readwrite', ALL_STORES, txn => { - // Verify that we still have the owner lease as part of every transaction. - return this.ensureOwnerLease(txn).next(() => operation(txn)); + if (requirePrimaryLease) { + // While we merely verify that we have (or can acquire) the lease + // immediately, we wait to extend the primary lease until after + // executing transactionOperation(). This ensures that even if the + // transactionOperation takes a long time, we'll use a recent + // leaseTimestampMs in the extended (or newly acquired) lease. + return this.canActAsPrimary(txn) + .next(canActAsPrimary => { + if (!canActAsPrimary) { + // TODO(multitab): Handle this gracefully and transition back to + // secondary state. + throw new FirestoreError( + Code.FAILED_PRECONDITION, + PRIMARY_LEASE_LOST_ERROR_MSG + ); + } + return transactionOperation(txn); + }) + .next(result => { + return this.acquireOrExtendPrimaryLease(txn).next(() => result); + }); + } else { + return transactionOperation(txn); + } }); } + /** + * Obtains or extends the new primary lease for the current client. This + * method does not verify that the client is eligible for this lease. + */ + private acquireOrExtendPrimaryLease(txn): PersistencePromise { + const newPrimary = new DbOwner(this.clientKey, Date.now()); + return ownerStore(txn).put('owner', newPrimary); + } + static isAvailable(): boolean { return SimpleDb.isAvailable(); } @@ -207,51 +396,15 @@ export class IndexedDbPersistence implements Persistence { return 'firestore/' + databaseInfo.persistenceKey + '/' + database + '/'; } - /** - * Acquires the owner lease if there's no valid owner. Else returns a rejected - * promise. - */ - private tryAcquireOwnerLease(): Promise { - // NOTE: Don't use this.runTransaction, since it requires us to already - // have the lease. - return this.simpleDb.runTransaction('readwrite', [DbOwner.store], txn => { - const store = txn.store(DbOwner.store); - return store.get('owner').next(dbOwner => { - if (!this.validOwner(dbOwner)) { - const newDbOwner = new DbOwner(this.ownerId, Date.now()); - log.debug( - LOG_TAG, - 'No valid owner. Acquiring owner lease. Current owner:', - dbOwner, - 'New owner:', - newDbOwner - ); - return store.put('owner', newDbOwner); - } else { - log.debug( - LOG_TAG, - 'Valid owner already. Failing. Current owner:', - dbOwner - ); - this.persistenceError = new FirestoreError( - Code.FAILED_PRECONDITION, - EXISTING_OWNER_ERROR_MSG - ); - return PersistencePromise.reject(this.persistenceError); - } - }); - }); - } + /** Checks the primary lease and removes it if we are the current primary. */ + private releasePrimaryLeaseIfHeld(): Promise { + this.isPrimary = false; - /** Checks the owner lease and deletes it if we are the current owner. */ - private releaseOwnerLease(): Promise { - // NOTE: Don't use this.runTransaction, since it requires us to already - // have the lease. return this.simpleDb.runTransaction('readwrite', [DbOwner.store], txn => { const store = txn.store(DbOwner.store); - return store.get('owner').next(dbOwner => { - if (dbOwner !== null && dbOwner.ownerId === this.ownerId) { - log.debug(LOG_TAG, 'Releasing owner lease.'); + return store.get('owner').next(primaryClient => { + if (this.isLocalClient(primaryClient)) { + log.debug(LOG_TAG, 'Releasing primary lease.'); return store.delete('owner'); } else { return PersistencePromise.resolve(); @@ -260,83 +413,46 @@ export class IndexedDbPersistence implements Persistence { }); } - /** - * Checks the owner lease and returns a rejected promise if we are not the - * current owner. This should be included in every transaction to guard - * against losing the owner lease. - */ - private ensureOwnerLease(txn: SimpleDbTransaction): PersistencePromise { - const store = txn.store(DbOwner.store); - return store.get('owner').next(dbOwner => { - if (dbOwner === null || dbOwner.ownerId !== this.ownerId) { - this.persistenceError = new FirestoreError( - Code.FAILED_PRECONDITION, - EXISTING_OWNER_ERROR_MSG - ); - return PersistencePromise.reject(this.persistenceError); - } else { - return PersistencePromise.resolve(); - } - }); - } - - /** - * Returns true if the provided owner exists, has a recent timestamp, and - * isn't zombied. - * - * NOTE: To determine if the owner is zombied, this method reads from - * LocalStorage which could be mildly expensive. - */ - private validOwner(dbOwner: DbOwner | null): boolean { + /** Verifies that `updateTimeMs` is within CLIENT_STATE_MAX_AGE_MS. */ + private isWithinMaxAge(updateTimeMs: number): boolean { const now = Date.now(); - const minAcceptable = now - OWNER_LEASE_MAX_AGE_MS; + const minAcceptable = now - CLIENT_METADATA_MAX_AGE_MS; const maxAcceptable = now; - if (dbOwner === null) { - return false; // no owner. - } else if (dbOwner.leaseTimestampMs < minAcceptable) { - return false; // owner lease has expired. - } else if (dbOwner.leaseTimestampMs > maxAcceptable) { + if (updateTimeMs < minAcceptable) { + return false; + } else if (updateTimeMs > maxAcceptable) { log.error( - 'Persistence owner-lease is in the future. Discarding.', - dbOwner + `Detected an update time that is in the future: ${updateTimeMs} > ${ + maxAcceptable + }` ); return false; - } else if (dbOwner.ownerId === this.getZombiedOwnerId()) { - return false; // owner's tab closed. - } else { - return true; } + + return true; } - /** - * Schedules a recurring timer to update the owner lease timestamp to prevent - * other tabs from taking the lease. - */ - private scheduleOwnerLeaseRefreshes(): void { - // NOTE: This doesn't need to be scheduled on the async queue and doing so - // would increase the chances of us not refreshing on time if the queue is - // backed up for some reason. - this.ownerLeaseRefreshHandle = setInterval(() => { - const txResult = this.runTransaction('Refresh owner timestamp', txn => { - // NOTE: We don't need to validate the current owner contents, since - // runTransaction does that automatically. - const store = txn.store(DbOwner.store); - return store.put('owner', new DbOwner(this.ownerId, Date.now())); + private attachVisibilityHandler(): void { + this.documentVisibilityHandler = () => { + this.queue.enqueue(() => { + this.inForeground = this.document.visibilityState === 'visible'; + return this.updateClientMetadataAndTryBecomePrimary(); }); + }; - txResult.catch(reason => { - // Probably means we lost the lease. Report the error and stop trying to - // refresh the lease. - log.error(reason); - this.stopOwnerLeaseRefreshes(); - }); - }, OWNER_LEASE_REFRESH_INTERVAL_MS); + this.document.addEventListener( + 'visibilitychange', + this.documentVisibilityHandler + ); } - private stopOwnerLeaseRefreshes(): void { - if (this.ownerLeaseRefreshHandle) { - clearInterval(this.ownerLeaseRefreshHandle); - this.ownerLeaseRefreshHandle = null; + private detachVisibilityHandler(): void { + if (this.documentVisibilityHandler) { + this.document.removeEventListener( + 'visibilitychange', + this.documentVisibilityHandler + ); + this.documentVisibilityHandler = null; } } @@ -351,19 +467,24 @@ export class IndexedDbPersistence implements Persistence { */ private attachWindowUnloadHook(): void { this.windowUnloadHandler = () => { - // Record that we're zombied. - this.setZombiedOwnerId(this.ownerId); - - // Attempt graceful shutdown (including releasing our owner lease), but - // there's no guarantee it will complete. - this.shutdown(); + // Note: In theory, this should be scheduled on the AsyncQueue since it + // accesses internal state. We execute this code directly during shutdown + // to make sure it gets a chance to run. + if (this.isPrimary) { + this.setZombiedClientId(this.clientKey); + } + this.queue.enqueue(() => { + // Attempt graceful shutdown (including releasing our owner lease), but + // there's no guarantee it will complete. + return this.shutdown(); + }); }; - window.addEventListener('unload', this.windowUnloadHandler); + this.window.addEventListener('unload', this.windowUnloadHandler); } private detachWindowUnloadHook(): void { if (this.windowUnloadHandler) { - window.removeEventListener('unload', this.windowUnloadHandler); + this.window.removeEventListener('unload', this.windowUnloadHandler); this.windowUnloadHandler = null; } } @@ -373,32 +494,36 @@ export class IndexedDbPersistence implements Persistence { * zombied due to their tab closing) from LocalStorage, or null if no such * record exists. */ - private getZombiedOwnerId(): string | null { + private getZombiedClientId(): ClientKey | null { try { - const zombiedOwnerId = window.localStorage.getItem( - this.zombiedOwnerLocalStorageKey() + const zombiedClientId = window.localStorage.getItem( + this.zombiedClientLocalStorageKey() ); - log.debug(LOG_TAG, 'Zombied ownerID from LocalStorage:', zombiedOwnerId); - return zombiedOwnerId; + log.debug( + LOG_TAG, + 'Zombied clientId from LocalStorage:', + zombiedClientId + ); + return zombiedClientId; } catch (e) { // Gracefully handle if LocalStorage isn't available / working. - log.error('Failed to get zombie owner id.', e); + log.error('Failed to get zombie client id.', e); return null; } } /** - * Records a zombied owner (an owner that had its tab closed) in LocalStorage - * or, if passed null, deletes any recorded zombied owner. + * Records a zombied primary client (a primary client that had its tab closed) + * in LocalStorage or, if passed null, deletes any recorded zombied owner. */ - private setZombiedOwnerId(zombieOwnerId: string | null) { + private setZombiedClientId(zombiedClientId: ClientKey | null) { try { - if (zombieOwnerId === null) { - window.localStorage.removeItem(this.zombiedOwnerLocalStorageKey()); + if (zombiedClientId === null) { + window.localStorage.removeItem(this.zombiedClientLocalStorageKey()); } else { window.localStorage.setItem( - this.zombiedOwnerLocalStorageKey(), - zombieOwnerId + this.zombiedClientLocalStorageKey(), + zombiedClientId ); } } catch (e) { @@ -407,12 +532,32 @@ export class IndexedDbPersistence implements Persistence { } } - private zombiedOwnerLocalStorageKey(): string { - return this.localStoragePrefix + ZOMBIE_OWNER_LOCALSTORAGE_SUFFIX; + private zombiedClientLocalStorageKey(): string { + return this.localStoragePrefix + ZOMBIED_PRIMARY_LOCALSTORAGE_SUFFIX; } - private generateOwnerId(): string { + private generateClientKey(): ClientKey { // For convenience, just use an AutoId. return AutoId.newId(); } } + +/** + * Helper to get a typed SimpleDbStore for the owner object store. + */ +function ownerStore( + txn: SimpleDbTransaction +): SimpleDbStore { + return txn.store(DbOwner.store); +} + +/** + * Helper to get a typed SimpleDbStore for the client metadata object store. + */ +function clientMetadataStore( + txn: SimpleDbTransaction +): SimpleDbStore { + return txn.store( + DbClientMetadata.store + ); +} diff --git a/packages/firestore/src/local/indexeddb_schema.ts b/packages/firestore/src/local/indexeddb_schema.ts index d3689ff94f1..1894d209af5 100644 --- a/packages/firestore/src/local/indexeddb_schema.ts +++ b/packages/firestore/src/local/indexeddb_schema.ts @@ -82,6 +82,8 @@ export type DbOwnerKey = 'owner'; * should regularly write an updated timestamp to prevent other tabs from * "stealing" ownership of the db. */ +// TODO(multitab): Rename this class to reflect the primary/secondary naming +// in the rest of the client. export class DbOwner { /** Name of the IndexedDb object store. */ static store = 'owner'; @@ -544,19 +546,22 @@ export class DbClientMetadata { /** Name of the IndexedDb object store. */ static store = 'clientMetadata'; - /** Keys are automatically assigned via the clientKey properties. */ + /** Keys are automatically assigned via the clientId properties. */ static keyPath = ['clientKey']; constructor( - /** The auto-generated client key assigned at client startup. */ + /** The auto-generated client id assigned at client startup. */ public clientKey: string, /** The last time this state was updated. */ - public updateTimeMs: DbTimestamp, + public updateTimeMs: number, /** Whether this client is running in a foreground tab. */ public inForeground: boolean ) {} } +/** Object keys in the 'clientMetadata' store are clientId strings. */ +export type DbClientMetadataKey = string; + function createClientMetadataStore(db: IDBDatabase): void { db.createObjectStore(DbClientMetadata.store, { keyPath: DbClientMetadata.keyPath as KeyPath diff --git a/packages/firestore/src/local/local_store.ts b/packages/firestore/src/local/local_store.ts index d8f1b84e16c..214bf186c6f 100644 --- a/packages/firestore/src/local/local_store.ts +++ b/packages/firestore/src/local/local_store.ts @@ -182,7 +182,8 @@ export class LocalStore { /** Performs any initial startup actions required by the local store. */ start(): Promise { - return this.persistence.runTransaction('Start LocalStore', txn => { + // TODO(multitab): Ensure that we in fact don't need the primary lease. + return this.persistence.runTransaction('Start LocalStore', false, txn => { return this.startMutationQueue(txn).next(() => this.startQueryCache(txn)); }); } @@ -194,7 +195,7 @@ export class LocalStore { * returns any resulting document changes. */ handleUserChange(user: User): Promise { - return this.persistence.runTransaction('Handle user change', txn => { + return this.persistence.runTransaction('Handle user change', true, txn => { // Swap out the mutation queue, grabbing the pending mutation batches // before and after. let oldBatches: MutationBatch[]; @@ -282,23 +283,27 @@ export class LocalStore { /* Accept locally generated Mutations and commit them to storage. */ localWrite(mutations: Mutation[]): Promise { - return this.persistence.runTransaction('Locally write mutations', txn => { - let batch: MutationBatch; - const localWriteTime = Timestamp.now(); - return this.mutationQueue - .addMutationBatch(txn, localWriteTime, mutations) - .next(promisedBatch => { - batch = promisedBatch; - // TODO(koss): This is doing an N^2 update by replaying ALL the - // mutations on each document (instead of just the ones added) in - // this batch. - const keys = batch.keys(); - return this.localDocuments.getDocuments(txn, keys); - }) - .next((changedDocuments: MaybeDocumentMap) => { - return { batchId: batch.batchId, changes: changedDocuments }; - }); - }); + return this.persistence.runTransaction( + 'Locally write mutations', + true, + txn => { + let batch: MutationBatch; + const localWriteTime = Timestamp.now(); + return this.mutationQueue + .addMutationBatch(txn, localWriteTime, mutations) + .next(promisedBatch => { + batch = promisedBatch; + // TODO(koss): This is doing an N^2 update by replaying ALL the + // mutations on each document (instead of just the ones added) in + // this batch. + const keys = batch.keys(); + return this.localDocuments.getDocuments(txn, keys); + }) + .next((changedDocuments: MaybeDocumentMap) => { + return { batchId: batch.batchId, changes: changedDocuments }; + }); + } + ); } /** @@ -318,7 +323,7 @@ export class LocalStore { acknowledgeBatch( batchResult: MutationBatchResult ): Promise { - return this.persistence.runTransaction('Acknowledge batch', txn => { + return this.persistence.runTransaction('Acknowledge batch', true, txn => { let affected: DocumentKeySet; return this.mutationQueue .acknowledgeBatch(txn, batchResult.batch, batchResult.streamToken) @@ -357,7 +362,7 @@ export class LocalStore { * @returns The resulting modified documents. */ rejectBatch(batchId: BatchId): Promise { - return this.persistence.runTransaction('Reject batch', txn => { + return this.persistence.runTransaction('Reject batch', true, txn => { let toReject: MutationBatch; let affectedKeys: DocumentKeySet; return this.mutationQueue @@ -394,9 +399,13 @@ export class LocalStore { /** Returns the last recorded stream token for the current user. */ getLastStreamToken(): Promise { - return this.persistence.runTransaction('Get last stream token', txn => { - return this.mutationQueue.getLastStreamToken(txn); - }); + return this.persistence.runTransaction( + 'Get last stream token', + false, // TODO(multitab): This requires the owner lease + txn => { + return this.mutationQueue.getLastStreamToken(txn); + } + ); } /** @@ -405,9 +414,13 @@ export class LocalStore { * response to an error that requires clearing the stream token. */ setLastStreamToken(streamToken: ProtoByteString): Promise { - return this.persistence.runTransaction('Set last stream token', txn => { - return this.mutationQueue.setLastStreamToken(txn, streamToken); - }); + return this.persistence.runTransaction( + 'Set last stream token', + true, + txn => { + return this.mutationQueue.setLastStreamToken(txn, streamToken); + } + ); } /** @@ -428,7 +441,7 @@ export class LocalStore { */ applyRemoteEvent(remoteEvent: RemoteEvent): Promise { const documentBuffer = new RemoteDocumentChangeBuffer(this.remoteDocuments); - return this.persistence.runTransaction('Apply remote event', txn => { + return this.persistence.runTransaction('Apply remote event', true, txn => { const promises = [] as Array>; objUtils.forEachNumber( remoteEvent.targetChanges, @@ -556,28 +569,35 @@ export class LocalStore { * Notify local store of the changed views to locally pin documents. */ notifyLocalViewChanges(viewChanges: LocalViewChanges[]): Promise { - return this.persistence.runTransaction('Notify local view changes', txn => { - const promises = [] as Array>; - for (const view of viewChanges) { - promises.push( - this.queryCache - .getQueryData(txn, view.query) - .next((queryData: QueryData | null) => { - assert( - queryData !== null, - 'Local view changes contain unallocated query.' - ); - const targetId = queryData!.targetId; - this.localViewReferences.addReferences(view.addedKeys, targetId); - this.localViewReferences.removeReferences( - view.removedKeys, - targetId - ); - }) - ); + return this.persistence.runTransaction( + 'Notify local view changes', + true, + txn => { + const promises = [] as Array>; + for (const view of viewChanges) { + promises.push( + this.queryCache + .getQueryData(txn, view.query) + .next((queryData: QueryData | null) => { + assert( + queryData !== null, + 'Local view changes contain unallocated query.' + ); + const targetId = queryData!.targetId; + this.localViewReferences.addReferences( + view.addedKeys, + targetId + ); + this.localViewReferences.removeReferences( + view.removedKeys, + targetId + ); + }) + ); + } + return PersistencePromise.waitFor(promises); } - return PersistencePromise.waitFor(promises); - }); + ); } /** @@ -587,15 +607,20 @@ export class LocalStore { * @returns The next mutation or null if there wasn't one. */ nextMutationBatch(afterBatchId?: BatchId): Promise { - return this.persistence.runTransaction('Get next mutation batch', txn => { - if (afterBatchId === undefined) { - afterBatchId = BATCHID_UNKNOWN; + // TODO(multitab): This needs to run in O(1). + return this.persistence.runTransaction( + 'Get next mutation batch', + false, + txn => { + if (afterBatchId === undefined) { + afterBatchId = BATCHID_UNKNOWN; + } + return this.mutationQueue.getNextMutationBatchAfterBatchId( + txn, + afterBatchId + ); } - return this.mutationQueue.getNextMutationBatchAfterBatchId( - txn, - afterBatchId - ); - }); + ); } /** @@ -603,7 +628,7 @@ export class LocalStore { * found - used for testing. */ readDocument(key: DocumentKey): Promise { - return this.persistence.runTransaction('read document', txn => { + return this.persistence.runTransaction('read document', true, txn => { return this.localDocuments.getDocument(txn, key); }); } @@ -614,7 +639,7 @@ export class LocalStore { * the store can be used to manage its view. */ allocateQuery(query: Query): Promise { - return this.persistence.runTransaction('Allocate query', txn => { + return this.persistence.runTransaction('Allocate query', true, txn => { let queryData: QueryData; return this.queryCache .getQueryData(txn, query) @@ -644,7 +669,7 @@ export class LocalStore { /** Unpin all the documents associated with the given query. */ releaseQuery(query: Query): Promise { - return this.persistence.runTransaction('Release query', txn => { + return this.persistence.runTransaction('Release query', true, txn => { return this.queryCache .getQueryData(txn, query) .next((queryData: QueryData | null) => { @@ -684,7 +709,7 @@ export class LocalStore { * returns the results. */ executeQuery(query: Query): Promise { - return this.persistence.runTransaction('Execute query', txn => { + return this.persistence.runTransaction('Execute query', true, txn => { return this.localDocuments.getDocumentsMatchingQuery(txn, query); }); } @@ -694,9 +719,13 @@ export class LocalStore { * target id in the remote table. */ remoteDocumentKeys(targetId: TargetId): Promise { - return this.persistence.runTransaction('Remote document keys', txn => { - return this.queryCache.getMatchingKeysForTargetId(txn, targetId); - }); + return this.persistence.runTransaction( + 'Remote document keys', + true, + txn => { + return this.queryCache.getMatchingKeysForTargetId(txn, targetId); + } + ); } /** @@ -708,7 +737,7 @@ export class LocalStore { collectGarbage(): Promise { // Call collectGarbage regardless of whether isGCEnabled so the referenceSet // doesn't continue to accumulate the garbage keys. - return this.persistence.runTransaction('Garbage collection', txn => { + return this.persistence.runTransaction('Garbage collection', true, txn => { return this.garbageCollector.collectGarbage(txn).next(garbage => { const promises = [] as Array>; garbage.forEach(key => { diff --git a/packages/firestore/src/local/memory_persistence.ts b/packages/firestore/src/local/memory_persistence.ts index f496ce5f522..86e1b43452d 100644 --- a/packages/firestore/src/local/memory_persistence.ts +++ b/packages/firestore/src/local/memory_persistence.ts @@ -22,10 +22,15 @@ import { MemoryMutationQueue } from './memory_mutation_queue'; import { MemoryQueryCache } from './memory_query_cache'; import { MemoryRemoteDocumentCache } from './memory_remote_document_cache'; import { MutationQueue } from './mutation_queue'; -import { Persistence, PersistenceTransaction } from './persistence'; +import { + Persistence, + PersistenceTransaction, + PrimaryStateListener +} from './persistence'; import { PersistencePromise } from './persistence_promise'; import { QueryCache } from './query_cache'; import { RemoteDocumentCache } from './remote_document_cache'; +import { AsyncQueue } from '../util/async_queue'; const LOG_TAG = 'MemoryPersistence'; @@ -47,6 +52,8 @@ export class MemoryPersistence implements Persistence { private started = false; + constructor(private readonly queue: AsyncQueue) {} + start(): Promise { assert(!this.started, 'MemoryPersistence double-started!'); this.started = true; @@ -61,6 +68,11 @@ export class MemoryPersistence implements Persistence { return Promise.resolve(); } + setPrimaryStateListener(primaryStateListener: PrimaryStateListener) { + // All clients using memory persistence act as primary. + this.queue.enqueue(() => primaryStateListener(true)); + } + getMutationQueue(user: User): MutationQueue { let queue = this.mutationQueues[user.toKey()]; if (!queue) { @@ -80,10 +92,13 @@ export class MemoryPersistence implements Persistence { runTransaction( action: string, - operation: (transaction: PersistenceTransaction) => PersistencePromise + requirePrimaryLease: boolean, + transactionOperation: ( + transaction: PersistenceTransaction + ) => PersistencePromise ): Promise { debug(LOG_TAG, 'Starting transaction:', action); - return operation(new MemoryPersistenceTransaction()).toPromise(); + return transactionOperation(new MemoryPersistenceTransaction()).toPromise(); } } diff --git a/packages/firestore/src/local/persistence.ts b/packages/firestore/src/local/persistence.ts index 499633b5e68..f1c3ae16664 100644 --- a/packages/firestore/src/local/persistence.ts +++ b/packages/firestore/src/local/persistence.ts @@ -31,6 +31,18 @@ import { RemoteDocumentCache } from './remote_document_cache'; */ export interface PersistenceTransaction {} +/** + * Callback type for primary state notifications. This callback can be + * registered with the persistence layer to get notified when we transition from + * primary to secondary state and vice versa. + * + * Note: Instances can only toggle between Primary and Secondary state if + * IndexedDB persistence is enabled and multiple clients are active. If this + * listener is registered with MemoryPersistence, the callback will be called + * exactly once marking the current instance as Primary. + */ +export type PrimaryStateListener = (isPrimary: boolean) => Promise; + /** * Persistence is the lowest-level shared interface to persistent storage in * Firestore. @@ -78,6 +90,13 @@ export interface Persistence { /** Releases any resources held during eager shutdown. */ shutdown(): Promise; + /** + * Registers a listener that gets called when the primary state of the + * instance changes. Upon registering, this listener is invoked immediately + * with the current primary state. + */ + setPrimaryStateListener(primaryStateListener: PrimaryStateListener); + /** * Returns a MutationQueue representing the persisted mutations for the * given user. @@ -121,11 +140,16 @@ export interface Persistence { * * @param action A description of the action performed by this transaction, * used for logging. + * @param requirePrimaryLease Whether this transaction can only be executed + * by the primary client. If the primary lease cannot be acquired, the + * transactionOperation will not be run, and the returned promise will be + * rejected with a FAILED_PRECONDITION error. * @param transactionOperation The operation to run inside a transaction. * @return A promise that is resolved once the transaction completes. */ runTransaction( action: string, + requirePrimaryLease: boolean, transactionOperation: ( transaction: PersistenceTransaction ) => PersistencePromise diff --git a/packages/firestore/src/local/shared_client_state.ts b/packages/firestore/src/local/shared_client_state.ts index e7fa2599280..bd9fb0b576c 100644 --- a/packages/firestore/src/local/shared_client_state.ts +++ b/packages/firestore/src/local/shared_client_state.ts @@ -25,6 +25,8 @@ import * as objUtils from '../util/obj'; const LOG_TAG = 'SharedClientState'; +// TODO(multitab): Change prefix of keys to "firestore_" to match IndexedDb. + // The format of the LocalStorage key that stores the client state is: // fs_clients__ const CLIENT_STATE_KEY_PREFIX = 'fs_clients'; @@ -32,6 +34,7 @@ const CLIENT_STATE_KEY_PREFIX = 'fs_clients'; /** * A randomly-generated key assigned to each Firestore instance at startup. */ +// TODO(multitab): Rename to ClientId. export type ClientKey = string; /** @@ -241,7 +244,7 @@ export class LocalClientState implements ClientState { /** * Converts this entry into a JSON-encoded format we can use for LocalStorage. - * Does not encode `clientKey` as it is part of the key in LocalStorage. + * Does not encode `clientId` as it is part of the key in LocalStorage. */ toLocalStorageJSON(): string { const data: ClientStateSchema = { diff --git a/packages/firestore/src/platform/platform.ts b/packages/firestore/src/platform/platform.ts index cb2ff6dc5b7..2dda1d34a54 100644 --- a/packages/firestore/src/platform/platform.ts +++ b/packages/firestore/src/platform/platform.ts @@ -27,6 +27,8 @@ import { AnyJs } from '../util/misc'; * * An implementation of this must be provided at compile time for the platform. */ +// TODO: Consider only exposing the APIs of 'document' and 'window' that we +// use in our client. export interface Platform { loadConnection(databaseInfo: DatabaseInfo): Promise; newSerializer(databaseId: DatabaseId): JsonProtoSerializer; @@ -40,6 +42,12 @@ export interface Platform { /** Converts a binary string to a Base64 encoded string. */ btoa(raw: string): string; + /** The Platform's 'window' implementation or null if not available. */ + readonly window: Window | null; + + /** The Platform's 'document' implementation or null if not available. */ + readonly document: Document | null; + /** True if and only if the Base64 conversion functions are available. */ readonly base64Available: boolean; diff --git a/packages/firestore/src/platform_browser/browser_platform.ts b/packages/firestore/src/platform_browser/browser_platform.ts index 157af2d7ee0..b3ee09a939a 100644 --- a/packages/firestore/src/platform_browser/browser_platform.ts +++ b/packages/firestore/src/platform_browser/browser_platform.ts @@ -27,6 +27,10 @@ export class BrowserPlatform implements Platform { readonly emptyByteString = ''; + readonly document = document; + + readonly window = window; + constructor() { this.base64Available = typeof atob !== 'undefined'; } diff --git a/packages/firestore/src/platform_node/node_platform.ts b/packages/firestore/src/platform_node/node_platform.ts index 8b507ddf6c5..df34f48fa64 100644 --- a/packages/firestore/src/platform_node/node_platform.ts +++ b/packages/firestore/src/platform_node/node_platform.ts @@ -31,6 +31,10 @@ export class NodePlatform implements Platform { readonly emptyByteString = new Uint8Array(0); + readonly document = null; + + readonly window = null; + loadConnection(databaseInfo: DatabaseInfo): Promise { const protos = loadProtos(); return Promise.resolve(new GrpcConnection(protos, databaseInfo)); diff --git a/packages/firestore/src/util/async_queue.ts b/packages/firestore/src/util/async_queue.ts index 453e720c373..e4aaab59033 100644 --- a/packages/firestore/src/util/async_queue.ts +++ b/packages/firestore/src/util/async_queue.ts @@ -32,7 +32,8 @@ export enum TimerId { ListenStreamIdle, ListenStreamConnection, WriteStreamIdle, - WriteStreamConnection + WriteStreamConnection, + ClientMetadataRefresh } /** @@ -217,6 +218,11 @@ export class AsyncQueue { ): CancelablePromise { this.verifyNotFailed(); + assert( + delayMs >= 0, + `Attempted to schedule an operation with a negative delay of ${delayMs}` + ); + // While not necessarily harmful, we currently don't expect to have multiple // ops with the same timer id in the queue, so defensively reject them. assert( diff --git a/packages/firestore/test/unit/local/mutation_queue.test.ts b/packages/firestore/test/unit/local/mutation_queue.test.ts index 98efa331ac0..f2ea4bf85e3 100644 --- a/packages/firestore/test/unit/local/mutation_queue.test.ts +++ b/packages/firestore/test/unit/local/mutation_queue.test.ts @@ -70,7 +70,7 @@ describe('IndexedDbMutationQueue', () => { describe('loadNextBatchIdFromDb', () => { function loadNextBatchId(): Promise { - return persistence.runTransaction('loadNextBatchIdFromDb', txn => { + return persistence.runTransaction('loadNextBatchIdFromDb', true, txn => { return IndexedDbMutationQueue.loadNextBatchIdFromDb(txn).next( batchId => { return batchId; @@ -80,7 +80,7 @@ describe('IndexedDbMutationQueue', () => { } function addDummyBatch(userId: string, batchId: BatchId): Promise { - return persistence.runTransaction('addDummyBatch', transaction => { + return persistence.runTransaction('addDummyBatch', true, transaction => { const txn = transaction as SimpleDbTransaction; const store = txn.store<[string, number], DbMutationBatch>( DbMutationBatch.store diff --git a/packages/firestore/test/unit/local/persistence_test_helpers.ts b/packages/firestore/test/unit/local/persistence_test_helpers.ts index 66936e358d3..e910830a7b4 100644 --- a/packages/firestore/test/unit/local/persistence_test_helpers.ts +++ b/packages/firestore/test/unit/local/persistence_test_helpers.ts @@ -25,6 +25,8 @@ import { ClientKey } from '../../../src/local/shared_client_state'; import { BatchId, TargetId } from '../../../src/core/types'; +import { BrowserPlatform } from '../../../src/platform_browser/browser_platform'; +import { AsyncQueue } from '../../../src/util/async_queue'; /** The persistence prefix used for testing in IndexedBD and LocalStorage. */ export const TEST_PERSISTENCE_PREFIX = 'PersistenceTestHelpers'; @@ -48,14 +50,22 @@ export async function testIndexedDbPersistence(): Promise< const serializer = new JsonProtoSerializer(partition, { useProto3Json: true }); - const persistence = new IndexedDbPersistence(prefix, serializer); + const platform = new BrowserPlatform(); + const queue = new AsyncQueue(); + const persistence = new IndexedDbPersistence( + prefix, + platform, + queue, + serializer + ); await persistence.start(); return persistence; } /** Creates and starts a MemoryPersistence instance for testing. */ export async function testMemoryPersistence(): Promise { - const persistence = new MemoryPersistence(); + const queue = new AsyncQueue(); + const persistence = new MemoryPersistence(queue); await persistence.start(); return persistence; } diff --git a/packages/firestore/test/unit/local/test_garbage_collector.ts b/packages/firestore/test/unit/local/test_garbage_collector.ts index 71bae6e4598..715c4c37d3d 100644 --- a/packages/firestore/test/unit/local/test_garbage_collector.ts +++ b/packages/firestore/test/unit/local/test_garbage_collector.ts @@ -27,7 +27,7 @@ export class TestGarbageCollector { collectGarbage(): Promise { return this.persistence - .runTransaction('garbageCollect', txn => { + .runTransaction('garbageCollect', true, txn => { return this.gc.collectGarbage(txn); }) .then(garbage => { diff --git a/packages/firestore/test/unit/local/test_mutation_queue.ts b/packages/firestore/test/unit/local/test_mutation_queue.ts index 5f5d9c121fd..974f5a9a518 100644 --- a/packages/firestore/test/unit/local/test_mutation_queue.ts +++ b/packages/firestore/test/unit/local/test_mutation_queue.ts @@ -34,27 +34,27 @@ export class TestMutationQueue { constructor(public persistence: Persistence, public queue: MutationQueue) {} start(): Promise { - return this.persistence.runTransaction('start', txn => { + return this.persistence.runTransaction('start', true, txn => { return this.queue.start(txn); }); } checkEmpty(): Promise { - return this.persistence.runTransaction('checkEmpty', txn => { + return this.persistence.runTransaction('checkEmpty', true, txn => { return this.queue.checkEmpty(txn); }); } countBatches(): Promise { return this.persistence - .runTransaction('countBatches', txn => { + .runTransaction('countBatches', true, txn => { return this.queue.getAllMutationBatches(txn); }) .then(batches => batches.length); } getNextBatchId(): Promise { - return this.persistence.runTransaction('getNextBatchId', txn => { + return this.persistence.runTransaction('getNextBatchId', true, txn => { return this.queue.getNextBatchId(txn); }); } @@ -62,6 +62,7 @@ export class TestMutationQueue { getHighestAcknowledgedBatchId(): Promise { return this.persistence.runTransaction( 'getHighestAcknowledgedBatchId', + true, txn => { return this.queue.getHighestAcknowledgedBatchId(txn); } @@ -72,31 +73,35 @@ export class TestMutationQueue { batch: MutationBatch, streamToken: ProtoByteString ): Promise { - return this.persistence.runTransaction('acknowledgeThroughBatchId', txn => { - return this.queue.acknowledgeBatch(txn, batch, streamToken); - }); + return this.persistence.runTransaction( + 'acknowledgeThroughBatchId', + true, + txn => { + return this.queue.acknowledgeBatch(txn, batch, streamToken); + } + ); } getLastStreamToken(): Promise { - return this.persistence.runTransaction('getLastStreamToken', txn => { + return this.persistence.runTransaction('getLastStreamToken', true, txn => { return this.queue.getLastStreamToken(txn); }) as AnyDuringMigration; } setLastStreamToken(streamToken: string): Promise { - return this.persistence.runTransaction('setLastStreamToken', txn => { + return this.persistence.runTransaction('setLastStreamToken', true, txn => { return this.queue.setLastStreamToken(txn, streamToken); }); } addMutationBatch(mutations: Mutation[]): Promise { - return this.persistence.runTransaction('addMutationBatch', txn => { + return this.persistence.runTransaction('addMutationBatch', true, txn => { return this.queue.addMutationBatch(txn, Timestamp.now(), mutations); }); } lookupMutationBatch(batchId: BatchId): Promise { - return this.persistence.runTransaction('lookupMutationBatch', txn => { + return this.persistence.runTransaction('lookupMutationBatch', true, txn => { return this.queue.lookupMutationBatch(txn, batchId); }); } @@ -106,6 +111,7 @@ export class TestMutationQueue { ): Promise { return this.persistence.runTransaction( 'getNextMutationBatchAfterBatchId', + true, txn => { return this.queue.getNextMutationBatchAfterBatchId(txn, batchId); } @@ -113,9 +119,13 @@ export class TestMutationQueue { } getAllMutationBatches(): Promise { - return this.persistence.runTransaction('getAllMutationBatches', txn => { - return this.queue.getAllMutationBatches(txn); - }); + return this.persistence.runTransaction( + 'getAllMutationBatches', + true, + txn => { + return this.queue.getAllMutationBatches(txn); + } + ); } getAllMutationBatchesThroughBatchId( @@ -123,6 +133,7 @@ export class TestMutationQueue { ): Promise { return this.persistence.runTransaction( 'getAllMutationBatchesThroughBatchId', + true, txn => { return this.queue.getAllMutationBatchesThroughBatchId(txn, batchId); } @@ -134,6 +145,7 @@ export class TestMutationQueue { ): Promise { return this.persistence.runTransaction( 'getAllMutationBatchesAffectingDocumentKey', + true, txn => { return this.queue.getAllMutationBatchesAffectingDocumentKey( txn, @@ -146,6 +158,7 @@ export class TestMutationQueue { getAllMutationBatchesAffectingQuery(query: Query): Promise { return this.persistence.runTransaction( 'getAllMutationBatchesAffectingQuery', + true, txn => { return this.queue.getAllMutationBatchesAffectingQuery(txn, query); } @@ -153,13 +166,17 @@ export class TestMutationQueue { } removeMutationBatches(batches: MutationBatch[]): Promise { - return this.persistence.runTransaction('removeMutationBatches', txn => { - return this.queue.removeMutationBatches(txn, batches); - }); + return this.persistence.runTransaction( + 'removeMutationBatches', + true, + txn => { + return this.queue.removeMutationBatches(txn, batches); + } + ); } collectGarbage(gc: GarbageCollector): Promise { - return this.persistence.runTransaction('garbageCollection', txn => { + return this.persistence.runTransaction('garbageCollection', true, txn => { return gc.collectGarbage(txn); }); } diff --git a/packages/firestore/test/unit/local/test_query_cache.ts b/packages/firestore/test/unit/local/test_query_cache.ts index eefe0032cdb..d4439ca52ea 100644 --- a/packages/firestore/test/unit/local/test_query_cache.ts +++ b/packages/firestore/test/unit/local/test_query_cache.ts @@ -31,25 +31,25 @@ export class TestQueryCache { constructor(public persistence: Persistence, public cache: QueryCache) {} start(): Promise { - return this.persistence.runTransaction('start', txn => + return this.persistence.runTransaction('start', true, txn => this.cache.start(txn) ); } addQueryData(queryData: QueryData): Promise { - return this.persistence.runTransaction('addQueryData', txn => { + return this.persistence.runTransaction('addQueryData', true, txn => { return this.cache.addQueryData(txn, queryData); }); } removeQueryData(queryData: QueryData): Promise { - return this.persistence.runTransaction('addQueryData', txn => { + return this.persistence.runTransaction('addQueryData', true, txn => { return this.cache.removeQueryData(txn, queryData); }); } getQueryData(query: Query): Promise { - return this.persistence.runTransaction('getQueryData', txn => { + return this.persistence.runTransaction('getQueryData', true, txn => { return this.cache.getQueryData(txn, query); }); } @@ -63,7 +63,7 @@ export class TestQueryCache { } addMatchingKeys(keys: DocumentKey[], targetId: TargetId): Promise { - return this.persistence.runTransaction('addMatchingKeys', txn => { + return this.persistence.runTransaction('addMatchingKeys', true, txn => { let set = documentKeySet(); for (const key of keys) { set = set.add(key); @@ -73,7 +73,7 @@ export class TestQueryCache { } removeMatchingKeys(keys: DocumentKey[], targetId: TargetId): Promise { - return this.persistence.runTransaction('removeMatchingKeys', txn => { + return this.persistence.runTransaction('removeMatchingKeys', true, txn => { let set = documentKeySet(); for (const key of keys) { set = set.add(key); @@ -84,7 +84,7 @@ export class TestQueryCache { getMatchingKeysForTargetId(targetId: TargetId): Promise { return this.persistence - .runTransaction('getMatchingKeysForTargetId', txn => { + .runTransaction('getMatchingKeysForTargetId', true, txn => { return this.cache.getMatchingKeysForTargetId(txn, targetId); }) .then(keySet => { @@ -97,6 +97,7 @@ export class TestQueryCache { removeMatchingKeysForTargetId(targetId: TargetId): Promise { return this.persistence.runTransaction( 'removeMatchingKeysForTargetId', + true, txn => { return this.cache.removeMatchingKeysForTargetId(txn, targetId); } @@ -104,7 +105,7 @@ export class TestQueryCache { } containsKey(key: DocumentKey): Promise { - return this.persistence.runTransaction('containsKey', txn => { + return this.persistence.runTransaction('containsKey', true, txn => { return this.cache.containsKey(txn, key); }); } @@ -112,6 +113,7 @@ export class TestQueryCache { setLastRemoteSnapshotVersion(version: SnapshotVersion) { return this.persistence.runTransaction( 'setLastRemoteSnapshotVersion', + true, txn => this.cache.setLastRemoteSnapshotVersion(txn, version) ); } diff --git a/packages/firestore/test/unit/local/test_remote_document_cache.ts b/packages/firestore/test/unit/local/test_remote_document_cache.ts index 4183c77c30a..9743af8cf7e 100644 --- a/packages/firestore/test/unit/local/test_remote_document_cache.ts +++ b/packages/firestore/test/unit/local/test_remote_document_cache.ts @@ -32,26 +32,30 @@ export class TestRemoteDocumentCache { ) {} addEntry(maybeDocument: MaybeDocument): Promise { - return this.persistence.runTransaction('addEntry', txn => { + return this.persistence.runTransaction('addEntry', true, txn => { return this.cache.addEntry(txn, maybeDocument); }); } removeEntry(documentKey: DocumentKey): Promise { - return this.persistence.runTransaction('removeEntry', txn => { + return this.persistence.runTransaction('removeEntry', true, txn => { return this.cache.removeEntry(txn, documentKey); }); } getEntry(documentKey: DocumentKey): Promise { - return this.persistence.runTransaction('getEntry', txn => { + return this.persistence.runTransaction('getEntry', true, txn => { return this.cache.getEntry(txn, documentKey); }); } getDocumentsMatchingQuery(query: Query): Promise { - return this.persistence.runTransaction('getDocumentsMatchingQuery', txn => { - return this.cache.getDocumentsMatchingQuery(txn, query); - }); + return this.persistence.runTransaction( + 'getDocumentsMatchingQuery', + true, + txn => { + return this.cache.getDocumentsMatchingQuery(txn, query); + } + ); } } diff --git a/packages/firestore/test/unit/local/test_remote_document_change_buffer.ts b/packages/firestore/test/unit/local/test_remote_document_change_buffer.ts index b2ef7918f19..38105d8b275 100644 --- a/packages/firestore/test/unit/local/test_remote_document_change_buffer.ts +++ b/packages/firestore/test/unit/local/test_remote_document_change_buffer.ts @@ -35,13 +35,13 @@ export class TestRemoteDocumentChangeBuffer { } getEntry(documentKey: DocumentKey): Promise { - return this.persistence.runTransaction('getEntry', txn => { + return this.persistence.runTransaction('getEntry', true, txn => { return this.buffer.getEntry(txn, documentKey); }); } apply(): Promise { - return this.persistence.runTransaction('apply', txn => { + return this.persistence.runTransaction('apply', true, txn => { return this.buffer.apply(txn); }); } diff --git a/packages/firestore/test/unit/specs/describe_spec.ts b/packages/firestore/test/unit/specs/describe_spec.ts index f59b302b818..d2345e8170e 100644 --- a/packages/firestore/test/unit/specs/describe_spec.ts +++ b/packages/firestore/test/unit/specs/describe_spec.ts @@ -24,22 +24,26 @@ import { SpecStep } from './spec_test_runner'; // Disables all other tests; useful for debugging. Multiple tests can have // this tag and they'll all be run (but all others won't). const EXCLUSIVE_TAG = 'exclusive'; -// Persistence-related tests. -const PERSISTENCE_TAG = 'persistence'; +// Multi-client related tests (which imply persistence). +const MULTI_CLIENT_TAG = 'multi-client'; // Explicit per-platform disable flags. const NO_WEB_TAG = 'no-web'; const NO_ANDROID_TAG = 'no-android'; const NO_IOS_TAG = 'no-ios'; const KNOWN_TAGS = [ EXCLUSIVE_TAG, - PERSISTENCE_TAG, + MULTI_CLIENT_TAG, NO_WEB_TAG, NO_ANDROID_TAG, NO_IOS_TAG ]; -const WEB_SPEC_TEST_FILTER = (tags: string[]) => - tags.indexOf(NO_WEB_TAG) === -1; +const WEB_SPEC_TEST_FILTER = (tags: string[], persistenceEnabled: boolean) => { + return ( + tags.indexOf(NO_WEB_TAG) === -1 && + (tags.indexOf(MULTI_CLIENT_TAG) === -1 || persistenceEnabled) + ); +}; // The format of one describeSpec written to a JSON file. interface SpecOutputFormat { @@ -69,6 +73,17 @@ export function setSpecJSONHandler(writer: (json: string) => void) { writeJSONFile = writer; } +/** Gets the test runner based on the specified tags. */ +function getTestRunner(tags, persistenceEnabled): Function { + if (!WEB_SPEC_TEST_FILTER(tags, persistenceEnabled)) { + return it.skip; + } else if (tags.indexOf(EXCLUSIVE_TAG) >= 0) { + return it.only; + } else { + return it; + } +} + /** * Like it(), but for spec tests. * @param name A name to give the test. @@ -98,14 +113,7 @@ export function specTest( : [false]; for (const usePersistence of persistenceModes) { const spec = builder(); - let runner: Function; - if (tags.indexOf(EXCLUSIVE_TAG) >= 0) { - runner = it.only; - } else if (!WEB_SPEC_TEST_FILTER(tags)) { - runner = it.skip; - } else { - runner = it; - } + const runner = getTestRunner(tags, usePersistence); const mode = usePersistence ? '(Persistence)' : '(Memory)'; const fullName = `${mode} ${name}`; runner(fullName, () => { diff --git a/packages/firestore/test/unit/specs/persistence_spec.test.ts b/packages/firestore/test/unit/specs/persistence_spec.test.ts index 081af702f5e..1acfb725f79 100644 --- a/packages/firestore/test/unit/specs/persistence_spec.test.ts +++ b/packages/firestore/test/unit/specs/persistence_spec.test.ts @@ -19,9 +19,9 @@ import { Query } from '../../../src/core/query'; import { doc, path } from '../../util/helpers'; import { describeSpec, specTest } from './describe_spec'; -import { spec } from './spec_builder'; +import { client, spec } from './spec_builder'; -describeSpec('Persistence:', ['persistence'], () => { +describeSpec('Persistence:', [], () => { specTest('Local mutations are persisted and re-sent', [], () => { return spec() .userSets('collection/key1', { foo: 'bar' }) @@ -182,4 +182,48 @@ describeSpec('Persistence:', ['persistence'], () => { }) ); }); + + specTest('Single tab acquires primary lease', ['multi-client'], () => { + // This test simulates primary state handoff between two background tabs. + // With all instances in the background, the first active tab acquires + // ownership. + return client(0) + .becomeHidden() + .expectPrimaryState(true) + .client(1) + .becomeHidden() + .expectPrimaryState(false) + .client(0) + .shutdown() + .client(1) + .tryAcquirePrimaryLease() + .expectPrimaryState(true); + }); + + specTest('Foreground tab acquires primary lease', ['multi-client'], () => { + // This test verifies that in a multi-client scenario, a foreground tab + // takes precedence when a new primary client is elected. + return ( + client(0) + .becomeHidden() + .expectPrimaryState(true) + .client(1) + .becomeHidden() + .expectPrimaryState(false) + .client(2) + .becomeVisible() + .expectPrimaryState(false) + .client(0) + // Shutdown the client that is currently holding the primary lease. + .shutdown() + .client(1) + // Client 1 is in the background and doesn't grab the primary lease as + // client 2 is in the foreground. + .tryAcquirePrimaryLease() + .expectPrimaryState(false) + .client(2) + .tryAcquirePrimaryLease() + .expectPrimaryState(true) + ); + }); }); diff --git a/packages/firestore/test/unit/specs/spec_builder.ts b/packages/firestore/test/unit/specs/spec_builder.ts index c59434b0f4e..58e3b865faf 100644 --- a/packages/firestore/test/unit/specs/spec_builder.ts +++ b/packages/firestore/test/unit/specs/spec_builder.ts @@ -50,11 +50,12 @@ import { * duplicate tests in every client. */ export class SpecBuilder { - private config: SpecConfig = { useGarbageCollection: true }; - private steps: SpecStep[] = []; + protected config: SpecConfig = { useGarbageCollection: true, numClients: 1 }; // currentStep is built up (in particular, expectations can be added to it) // until nextStep() is called to append it to steps. - private currentStep: SpecStep | null = null; + protected currentStep: SpecStep | null = null; + + private steps: SpecStep[] = []; private queryMapping: { [query: string]: TargetId } = {}; private limboMapping: { [key: string]: TargetId } = {}; @@ -194,6 +195,24 @@ export class SpecBuilder { return this; } + // PORTING NOTE: Only used by web multi-tab tests. + becomeHidden(): SpecBuilder { + this.nextStep(); + this.currentStep = { + applyClientState: { visibility: 'hidden' } + }; + return this; + } + + // PORTING NOTE: Only used by web multi-tab tests. + becomeVisible(): SpecBuilder { + this.nextStep(); + this.currentStep = { + applyClientState: { visibility: 'visible' } + }; + return this; + } + changeUser(uid: string | null): SpecBuilder { this.nextStep(); this.currentStep = { changeUser: uid }; @@ -229,15 +248,44 @@ export class SpecBuilder { limboDocs: [] } }; + // Reset our mappings / target ids since all existing listens will be + // forgotten. + this.resetInMemoryState(); + return this; + } + + // TODO: Replace with .runTimer(TimerId.ClientStateRefresh) once #412 is + // merged. + // PORTING NOTE: Only used by web multi-tab tests. + tryAcquirePrimaryLease(): SpecBuilder { + this.nextStep(); + this.currentStep = { + acquirePrimaryLease: true + }; + return this; + } + shutdown(): SpecBuilder { + this.nextStep(); + this.currentStep = { + shutdown: true, + stateExpect: { + activeTargets: {}, + limboDocs: [] + } + }; // Reset our mappings / target ids since all existing listens will be - // forgotten + // forgotten. + this.resetInMemoryState(); + return this; + } + + private resetInMemoryState(): void { this.queryMapping = {}; this.limboMapping = {}; this.activeTargets = {}; this.queryIdGenerator = TargetIdGenerator.forLocalStore(); this.limboIdGenerator = TargetIdGenerator.forSyncEngine(); - return this; } /** Overrides the currently expected set of active targets. */ @@ -569,6 +617,14 @@ export class SpecBuilder { return this; } + expectPrimaryState(isPrimary: boolean): SpecBuilder { + this.assertStep('Expectations requires previous step'); + const currentStep = this.currentStep!; + currentStep.stateExpect = currentStep.stateExpect || {}; + currentStep.stateExpect.isPrimary = isPrimary; + return this; + } + private static queryToSpec(query: Query): SpecQuery { // TODO(dimond): full query support const spec: SpecQuery = { path: query.path.canonicalString() }; @@ -616,7 +672,7 @@ export class SpecBuilder { return key.path.canonicalString(); } - private nextStep(): void { + protected nextStep(): void { if (this.currentStep !== null) { this.steps.push(this.currentStep); this.currentStep = null; @@ -646,6 +702,251 @@ export class SpecBuilder { } } -export function spec() { +/** + * SpecBuilder that supports serialized interactions between different clients. + * + * Use `client(clientIndex)` to switch between clients. + */ +// PORTING NOTE: Only used by web multi-tab tests. +export class MultiClientSpecBuilder extends SpecBuilder { + // TODO(multitab): Consider merging this with SpecBuilder. + private activeClientIndex = -1; + + client(clientIndex: number): MultiClientSpecBuilder { + // Since `currentStep` is fully self-contained and does not rely on previous + // state, we don't need to use a different SpecBuilder instance for each + // client. + this.nextStep(); + this.activeClientIndex = clientIndex; + this.config.numClients = Math.max( + this.config.numClients, + this.activeClientIndex + 1 + ); + return this; + } + + protected nextStep() { + if (this.currentStep !== null) { + this.currentStep.clientIndex = this.activeClientIndex; + } + super.nextStep(); + } + + withGCEnabled(gcEnabled: boolean): MultiClientSpecBuilder { + super.withGCEnabled(gcEnabled); + return this; + } + + userListens(query: Query, resumeToken?: string): MultiClientSpecBuilder { + super.userListens(query, resumeToken); + return this; + } + + restoreListen(query: Query, resumeToken: string): MultiClientSpecBuilder { + super.restoreListen(query, resumeToken); + return this; + } + + userUnlistens(query: Query): MultiClientSpecBuilder { + super.userUnlistens(query); + return this; + } + + userSets(key: string, value: JsonObject): MultiClientSpecBuilder { + super.userSets(key, value); + return this; + } + + userPatches(key: string, value: JsonObject): MultiClientSpecBuilder { + super.userPatches(key, value); + return this; + } + + userDeletes(key: string): MultiClientSpecBuilder { + super.userDeletes(key); + return this; + } + + becomeHidden(): MultiClientSpecBuilder { + super.becomeHidden(); + return this; + } + + becomeVisible(): MultiClientSpecBuilder { + super.becomeVisible(); + return this; + } + + changeUser(uid: string | null): MultiClientSpecBuilder { + super.changeUser(uid); + return this; + } + + disableNetwork(): MultiClientSpecBuilder { + super.disableNetwork(); + return this; + } + + enableNetwork(): MultiClientSpecBuilder { + super.enableNetwork(); + return this; + } + + restart(): MultiClientSpecBuilder { + super.restart(); + return this; + } + + tryAcquirePrimaryLease(): MultiClientSpecBuilder { + super.tryAcquirePrimaryLease(); + return this; + } + + expectActiveTargets(...targets): MultiClientSpecBuilder { + super.expectActiveTargets(...targets); + return this; + } + + expectLimboDocs(...keys): MultiClientSpecBuilder { + super.expectLimboDocs(...keys); + return this; + } + + ackLimbo( + version: TestSnapshotVersion, + doc: Document | NoDocument + ): MultiClientSpecBuilder { + super.ackLimbo(version, doc); + return this; + } + + watchRemovesLimboTarget(doc: Document | NoDocument): MultiClientSpecBuilder { + super.watchRemovesLimboTarget(doc); + return this; + } + + writeAcks( + version: TestSnapshotVersion, + options?: { expectUserCallback: boolean } + ): MultiClientSpecBuilder { + super.writeAcks(version, options); + return this; + } + + failWrite( + err: RpcError, + options?: { expectUserCallback: boolean } + ): MultiClientSpecBuilder { + super.failWrite(err, options); + return this; + } + + watchAcks(query: Query): MultiClientSpecBuilder { + super.watchAcks(query); + return this; + } + + watchCurrents(query: Query, resumeToken: string): MultiClientSpecBuilder { + super.watchCurrents(query, resumeToken); + return this; + } + + watchRemoves(query: Query, cause?: RpcError): MultiClientSpecBuilder { + super.watchRemoves(query, cause); + return this; + } + + watchSends( + targets: { affects?: Query[]; removed?: Query[] }, + ...docs + ): MultiClientSpecBuilder { + super.watchSends(targets, ...docs); + return this; + } + + watchRemovesDoc(key: DocumentKey, ...targets): MultiClientSpecBuilder { + super.watchRemovesDoc(key, ...targets); + return this; + } + + watchFilters(queries: Query[], ...docs): MultiClientSpecBuilder { + super.watchFilters(queries, ...docs); + return this; + } + + watchResets(...queries): MultiClientSpecBuilder { + super.watchResets(...queries); + return this; + } + + watchSnapshots(version: TestSnapshotVersion): MultiClientSpecBuilder { + super.watchSnapshots(version); + return this; + } + + watchAcksFull( + query: Query, + version: TestSnapshotVersion, + ...docs + ): MultiClientSpecBuilder { + super.watchAcksFull(query, version, ...docs); + return this; + } + + watchStreamCloses(error: Code): MultiClientSpecBuilder { + super.watchStreamCloses(error); + return this; + } + + expectEvents( + query: Query, + events: { + fromCache?: boolean; + hasPendingWrites?: boolean; + added?: Document[]; + modified?: Document[]; + removed?: Document[]; + metadata?: Document[]; + errorCode?: Code; + } + ): MultiClientSpecBuilder { + super.expectEvents(query, events); + return this; + } + + expectWatchStreamRequestCount(num: number): MultiClientSpecBuilder { + super.expectWatchStreamRequestCount(num); + return this; + } + + expectNumOutstandingWrites(num: number): MultiClientSpecBuilder { + super.expectNumOutstandingWrites(num); + return this; + } + + expectPrimaryState(isPrimary: boolean): MultiClientSpecBuilder { + super.expectPrimaryState(isPrimary); + return this; + } + + expectWriteStreamRequestCount(num: number): MultiClientSpecBuilder { + super.expectWriteStreamRequestCount(num); + return this; + } + + shutdown(): MultiClientSpecBuilder { + super.shutdown(); + return this; + } +} + +/** Starts a new single-client SpecTest. */ +export function spec(): SpecBuilder { return new SpecBuilder(); } + +/** Starts a new multi-client SpecTest. */ +// PORTING NOTE: Only used by web multi-tab tests. +export function client(num: number): MultiClientSpecBuilder { + return new MultiClientSpecBuilder().client(num); +} diff --git a/packages/firestore/test/unit/specs/spec_test_runner.ts b/packages/firestore/test/unit/specs/spec_test_runner.ts index 261870162c6..0a049913380 100644 --- a/packages/firestore/test/unit/specs/spec_test_runner.ts +++ b/packages/firestore/test/unit/specs/spec_test_runner.ts @@ -50,7 +50,11 @@ import { DocumentOptions } from '../../../src/model/document'; import { DocumentKey } from '../../../src/model/document_key'; import { JsonObject } from '../../../src/model/field_value'; import { Mutation } from '../../../src/model/mutation'; -import { emptyByteString } from '../../../src/platform/platform'; +import { + emptyByteString, + Platform, + PlatformSupport +} from '../../../src/platform/platform'; import { Connection, Stream } from '../../../src/remote/connection'; import { Datastore } from '../../../src/remote/datastore'; import { ExistenceFilter } from '../../../src/remote/existence_filter'; @@ -316,10 +320,11 @@ interface OutstandingWrite { } abstract class TestRunner { + protected queue: AsyncQueue; + private connection: MockConnection; private eventManager: EventManager; private syncEngine: SyncEngine; - private queue: AsyncQueue; private eventList: QueryEvent[] = []; private outstandingWrites: OutstandingWrite[] = []; @@ -342,13 +347,18 @@ abstract class TestRunner { private serializer: JsonProtoSerializer; - constructor(private readonly name: string, config: SpecConfig) { + constructor( + private readonly name: string, + protected readonly platform: TestPlatform, + config: SpecConfig + ) { this.databaseInfo = new DatabaseInfo( new DatabaseId('project'), 'persistenceKey', 'host', false ); + this.queue = new AsyncQueue(); this.serializer = new JsonProtoSerializer(this.databaseInfo.databaseId, { useProto3Json: true }); @@ -371,7 +381,6 @@ abstract class TestRunner { garbageCollector ); - this.queue = new AsyncQueue(); this.connection = new MockConnection(this.queue); this.datastore = new Datastore( this.queue, @@ -410,30 +419,30 @@ abstract class TestRunner { protected abstract getPersistence( serializer: JsonProtoSerializer ): Persistence; - protected abstract destroyPersistence(): Promise; async start(): Promise { this.connection.reset(); await this.persistence.start(); await this.localStore.start(); await this.remoteStore.start(); + + this.persistence.setPrimaryStateListener(isPrimary => + this.syncEngine.applyPrimaryState(isPrimary) + ); } async shutdown(): Promise { await this.remoteStore.shutdown(); await this.persistence.shutdown(); - await this.destroyPersistence(); } - run(steps: SpecStep[]): Promise { - console.log('Running spec: ' + this.name); - return sequence(steps, async step => { - await this.doStep(step); - await this.queue.drain(); - this.validateStepExpectations(step.expect!); - this.validateStateExpectations(step.stateExpect!); - this.eventList = []; - }); + /** Runs a single SpecStep on this runner. */ + async run(step: SpecStep): Promise { + await this.doStep(step); + await this.queue.drain(); + this.validateStepExpectations(step.expect!); + this.validateStateExpectations(step.stateExpect!); + this.eventList = []; } private doStep(step: SpecStep): Promise { @@ -469,9 +478,16 @@ abstract class TestRunner { return step.enableNetwork! ? this.doEnableNetwork() : this.doDisableNetwork(); + } else if ('acquirePrimaryLease' in step) { + // PORTING NOTE: Only used by web multi-tab tests. + return this.doAcquirePrimaryLease(); } else if ('restart' in step) { - assert(step.restart!, 'Restart cannot be false'); return this.doRestart(); + } else if ('shutdown' in step) { + return this.doShutdown(); + } else if ('applyClientState' in step) { + // PORTING NOTE: Only used by web multi-tab tests. + return this.doApplyClientState(step.applyClientState!); } else if ('changeUser' in step) { return this.doChangeUser(step.changeUser!); } else { @@ -778,6 +794,19 @@ abstract class TestRunner { await this.remoteStore.enableNetwork(); } + private async doAcquirePrimaryLease(): Promise { + // We drain the queue after running the client metadata refresh task as the + // refresh might schedule a primary state callback on the queue as well. + return this.queue + .runDelayedOperationsEarly(TimerId.ClientMetadataRefresh) + .then(() => this.queue.drain()); + } + + private async doShutdown(): Promise { + await this.remoteStore.shutdown(); + await this.persistence.shutdown(); + } + private async doRestart(): Promise { // Reinitialize everything, except the persistence. // No local store to shutdown. @@ -793,6 +822,13 @@ abstract class TestRunner { }); } + private doApplyClientState(state: SpecClientState): Promise { + if (state.visibility) { + this.platform.raiseVisibilityEvent(state.visibility!); + } + return Promise.resolve(); + } + private doChangeUser(user: string | null): Promise { this.user = new User(user); return this.queue.enqueue(() => @@ -842,6 +878,9 @@ abstract class TestRunner { if ('activeTargets' in expectation) { this.expectedActiveTargets = expectation.activeTargets!; } + if ('isPrimary' in expectation) { + expect(this.syncEngine.isPrimaryClient).to.eq(expectation.isPrimary!); + } } // Always validate that the expected limbo docs match the actual limbo docs @@ -996,15 +1035,92 @@ abstract class TestRunner { class MemoryTestRunner extends TestRunner { protected getPersistence(serializer: JsonProtoSerializer): Persistence { - return new MemoryPersistence(); + return new MemoryPersistence(this.queue); } +} - protected destroyPersistence(): Promise { - // Nothing to do. - return Promise.resolve(); +/** + * `Document` mock that implements the `visibilitychange` API used by Firestore. + */ +class MockDocument { + private _visibilityState: VisibilityState = 'unloaded'; + private visibilityListener: EventListener | null; + + get visibilityState(): VisibilityState { + return this._visibilityState; + } + + addEventListener(type: string, listener: EventListener) { + assert( + type === 'visibilitychange', + "MockDocument only supports events of type 'visibilitychange'" + ); + this.visibilityListener = listener; + } + + removeEventListener(type: string, listener: EventListener) { + if (listener === this.visibilityListener) { + this.visibilityListener = null; + } + } + + raiseVisibilityEvent(visibility: VisibilityState) { + this._visibilityState = visibility; + if (this.visibilityListener) { + this.visibilityListener(new Event('visibilitychange')); + } } } +/** + * Implementation of `Platform` that allows mocking of `document` and `window`. + */ +class TestPlatform implements Platform { + private mockDocument = new MockDocument(); + + constructor(private readonly basePlatform: Platform) {} + + get window(): Window | null { + return this.basePlatform.window; + } + + get document(): Document { + // tslint:disable-next-line:no-any MockDocument doesn't support full Document interface. + return (this.mockDocument as any) as Document; + } + + get base64Available(): boolean { + return this.basePlatform.base64Available; + } + + get emptyByteString(): ProtoByteString { + return this.basePlatform.emptyByteString; + } + + raiseVisibilityEvent(visibility: VisibilityState) { + this.mockDocument.raiseVisibilityEvent(visibility); + } + + loadConnection(databaseInfo: DatabaseInfo): Promise { + return this.basePlatform.loadConnection(databaseInfo); + } + + newSerializer(databaseId: DatabaseId): JsonProtoSerializer { + return this.basePlatform.newSerializer(databaseId); + } + + formatJSON(value: AnyJs): string { + return this.basePlatform.formatJSON(value); + } + + atob(encoded: string): string { + return this.basePlatform.atob(encoded); + } + + btoa(raw: string): string { + return this.basePlatform.btoa(raw); + } +} /** * Runs the specs using IndexedDbPersistence, the creator must ensure that it is * enabled for the platform. @@ -1015,11 +1131,13 @@ class IndexedDbTestRunner extends TestRunner { protected getPersistence(serializer: JsonProtoSerializer): Persistence { return new IndexedDbPersistence( IndexedDbTestRunner.TEST_DB_NAME, + this.platform, + this.queue, serializer ); } - protected destroyPersistence(): Promise { + static destroyPersistence(): Promise { return SimpleDb.delete( IndexedDbTestRunner.TEST_DB_NAME + IndexedDbPersistence.MAIN_DATABASE ); @@ -1037,17 +1155,37 @@ export async function runSpec( config: SpecConfig, steps: SpecStep[] ): Promise { - let runner: TestRunner; - if (usePersistence) { - runner = new IndexedDbTestRunner(name, config); - } else { - runner = new MemoryTestRunner(name, config); + console.log('Running spec: ' + name); + const platform = new TestPlatform(PlatformSupport.getPlatform()); + let runners: TestRunner[] = []; + for (let i = 0; i < config.numClients; ++i) { + if (usePersistence) { + runners.push(new IndexedDbTestRunner(name, platform, config)); + } else { + runners.push(new MemoryTestRunner(name, platform, config)); + } + await runners[i].start(); } - await runner.start(); + let lastStep = null; + let count = 0; try { - await runner.run(steps); + await sequence(steps, async step => { + ++count; + lastStep = step; + return runners[step.clientIndex || 0].run(step); + }); + } catch (err) { + console.warn( + `Spec test failed at step ${count}: ${JSON.stringify(lastStep)}` + ); + throw err; } finally { - await runner.shutdown(); + for (const runner of runners) { + await runner.shutdown(); + } + if (usePersistence) { + await IndexedDbTestRunner.destroyPersistence(); + } } } @@ -1055,6 +1193,9 @@ export async function runSpec( export interface SpecConfig { /** A boolean to enable / disable GC. */ useGarbageCollection: boolean; + + /** The number of active clients for this test run. */ + numClients: number; } /** @@ -1062,6 +1203,8 @@ export interface SpecConfig { * set and optionally expected events in the `expect` field. */ export interface SpecStep { + /** The index of the current client for multi-client spec tests. */ + clientIndex?: number; // PORTING NOTE: Only used by web multi-tab tests /** Listen to a new query (must be unique) */ userListen?: SpecUserListen; /** Unlisten from a query (must be listened to) */ @@ -1101,16 +1244,24 @@ export interface SpecStep { /** Enable or disable RemoteStore's network connection. */ enableNetwork?: boolean; + /** Changes the metadata state of a client instance. */ + applyClientState?: SpecClientState; // PORTING NOTE: Only used by web multi-tab tests + /** Change to a new active user (specified by uid or null for anonymous). */ changeUser?: string | null; + /** Attempt to acquire the primary lease. */ + acquirePrimaryLease?: true; // PORTING NOTE: Only used by web multi-tab tests + /** * Restarts the SyncEngine from scratch, except re-uses persistence and auth * components. This allows you to queue writes, get documents into cache, * etc. and then simulate an app restart. */ - restart?: boolean; + restart?: true; + /** Shut down the client and close it network connection. */ + shutdown?: true; /** * Optional list of expected events. * If not provided, the test will fail if the step causes events to be raised. @@ -1190,6 +1341,12 @@ export interface SpecWatchEntity { removedTargets?: TargetId[]; } +// PORTING NOTE: Only used by web multi-tab tests. +export type SpecClientState = { + /** The visibility state of the browser tab running the client. */ + visibility?: VisibilityState; +}; + /** * [[, ...], , ...] * Note that the last parameter is really of type ...string (spread operator) @@ -1251,6 +1408,10 @@ export interface StateExpectation { watchStreamRequestCount?: number; /** Current documents in limbo. Verified in each step until overwritten. */ limboDocs?: string[]; + /** + * Whether the instance holds the primary lease. Used in multi-client tests. + */ + isPrimary?: boolean; /** * Current expected active targets. Verified in each step until overwritten. */