FirebaseExtended / emberfire

The officially supported adapter for using Firebase with Ember

Home Page:https://firebaseopensource.com/projects/firebaseextended/emberfire/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Duplication in this.store when creating a record on a route using RealtimeRouteMixin

iofusion opened this issue · comments

Version info

index.js:185 DEBUG: Ember      : 3.10.2
index.js:185 DEBUG: Ember Data : 3.10.0
index.js:185 DEBUG: jQuery     : 3.4.1
index.js:185 DEBUG: EmberFire  : 3.0.0-rc.3
index.js:185 DEBUG: Firebase   : 6.2.2
index.js:185 DEBUG: -------------------------------

When creating and saving a Firestore Record on a route using RealtimeRouteMixin the following error occurs Uncaught (in promise) Error: Assertion Failed: 'nameOfModel' was saved to the server, but the response returned the new id 'HYplP17VdPrSjDwZd8Ig', which has already been used with another record.'

A single copy of the record is correctly saved to Firestore, but the local store contains two copies of it; One copy has the correct Firestore value for its id, the other copy has null for its id and the following unsaved indicators {isDirty:true, isSaving:true hasDirtyAttributes:true dirtyTYpe: "created"}

The error and duplication in the store do not occur if the RealtimeRouteMixin is removed form the route.

The error above is is being thrown by store._setRecordId

    assert(
      `'${modelName}' was saved to the server, but the response returned the new id '${id}', which has already been used with another record.'`,
      isNone(existingInternalModel) || existingInternalModel === internalModel
    );

and is due to a race between createRecord and the RealtimeListener. Both are trying to update the store with the same record. Before the createRecord call stack completes, the realtimeListener store.pushes the updated record into the store; once createRecord’s callStack reaches _setRecordId, that function fails since the realtimeListener has already pushed the record (with the same id) into the store.

I was able to avoid the error situation by manually unsubscribing the route from realtime listening before using createRecord; and then manually subscribing again when the save promise fulfilled.

I am still working toward understanding this stack and believe that manually managing the realtimeListener subscription can be avoided. I am updating this issue as I go and would welcome advice and direction. This is as deep as I have gone into both ember-data and firebase.

The firebase realtimeUpdate that is triggering the realtimeListener is a local change, the data is not yet in Firebase. By inspecting doc.metadata.hasPendingWrites (it is true during this phase of the record's creation) createdRecord events can be handled differently by the realtimeListner. By updating realtime-listener.js as follows, the raceCondition is avoided and the expected behaviour occurs. Obviously this is not a solution, just an aid to scoping the problem.

Existing realtime-listener.js code before changes.

Update that seems to confirm scope of problem:

              switch (change.type) {
                case 'added':
                  {
                    const current = model.content.objectAt(change.newIndex);
                    if (current == null || current.id !== change.doc.id) {

                      // doc.metadata.hasPendingWrites is true when this is a
                      // local change (this record was created locally) so
                      // avoid pushing into the store since createRecord is also

                      if (change.doc.metadata.hasPendingWrites) {
                        Ember.run.later(() => {

                          // Instead get the resulting _internamModel from the store
                          // after a short wait to allow createRecord to complete.
                          // Shouldn't createRecord update the routes model (DS.RecordArray)?

                          // - the next line was removed
                          // const doc = store.push(normalizedData);

                          // + the next line was added
                          const doc = store.peekRecord('shift', change.doc.id);

                          model.content.insertAt(change.newIndex, doc._internalModel);
                        }, 666);
                      } else {
                        const doc = store.push(normalizedData);
                        model.content.insertAt(change.newIndex, doc._internalModel);
                      }
                    }

I am going to see if someone on Discord with more ember-data experience can advise me on the best way to update the routes model when createRecord completes. If that is an option perhaps realtime-listener.js can ignore added local changes..?

After some more digging around...

The realtime duplication issue that occurs when store.createRecord (+save) and realtime-listeners' store.push manage the same record simultaneously, is due to the way ember-data manages identity of records without an id. When store.createRecord is not supplied an id, it begins by creating a clientId to manage identity. When a clientId is present, the callStack for both store.createRecord and store.push change; that change creates the identity problem that results in the duplication issue.

ember-data is preparing to update its internal Identifiers and it probably makes sense to participate in that rfc's maturity. Without updates to ember-data the simplest way to avoid the realtime duplication issue is to avoid ember-data's use of clientId by supplying an id for createRecord, or by disabling the realtime subscription during createRecord.

Disabling the routes realtime subscription during createRecord

In the route route.setupController is used to place an instance of the route on the controller. The realtime-listener uses that instance to identify the subscription.

import Route from '@ember/routing/route';
import RealtimeRouteMixin from 'emberfire/mixins/realtime-route';

export default Route.extend(RealtimeRouteMixin, {

  setupController(controller, model){
    this._super(controller, model);
    this.controllerFor('application').set('routeInstance', this);
  },

  model: function() {
    return this.store.query(...
  }
});

In the controller store.createRecord is insulated from the realtime-listener with an unsubscribe/subscribe pair.

import Controller from '@ember/controller';
import { subscribe, unsubscribe } from 'emberfire/services/realtime-listener';

export default Controller.extend({

  routeInstance,

  actions: {
    create(){
      unsubscribe(this.routeInstance, this.model);
      let newRecord = this.store.createRecord('modelName', {
          ...
      });
      newRecord.save().then(() => {
        subscribe(this.routeInstance, this.model);
        }, function(failure) {
          debug(failure);
        })
    },

Suppling a local uuid for createRecord

store.createRecord gives the adpater a chance to provide a local id for a new record with generateIdForRecord

import FirestoreAdapter from 'emberfire/adapters/firestore';
import { v4 } from 'ember-uuid';

export default FirestoreAdapter.extend({

  generateIdForRecord() {
    return v4();
  },

});

I tried using that function to await an id from Firebase but store.coerceId ended up stringifying the promise. Intefering with ember-data's backburner queues seems dangerous... The docs for generateIdForRecord recommend using didCommit for a server generated id but is too late in the cycle to avoid the identity failure.

Supplying a firebase id for createRecord

Since adapter.generateIdForRecord is too late in the stack to request a firebase id, a service is used to generate the key and await that key in createRecord.

import Service, { inject as service } from '@ember/service';
import { pluralize } from 'ember-inflector';

export default Service.extend({
  firebaseApp: service(),

  async generateIdForModelName(modelName) {
    let db = await this.firebaseApp.firestore();
    let key = await db.collection(pluralize(modelName)).doc().id;
    return key;
  },

});

In the controller...

import Controller from '@ember/controller';
import { debug } from '@ember/debug';
import { inject as service } from '@ember/service';

export default Controller.extend({
  identity: service(),

  actions: {
    async create(){
      let newShift = this.store.createRecord('modelName', {
        id: await this.identity.generateIdForModelName('modelName'),
        ...
      });
      newShift.save().then(() => {
        }, function(failure) {
          debug(failure);
        })
    }
});

I would not count this as resolved, but perhaps these solutions will be helpful to others.

Thanks for all the digging around on this. Definitely an interesting race.

Appreciate the help here, I haven't had much time to give all of this a thorough testing.

I think the id generation client side is the route we'll want to go about. We could probably generate an identifier (if needed) on createRecord in the adapter. As right now it just calls:

193: return rootCollection(this, type).then(collection => collection.add(data)).then(doc => doc.get())

Ultimately, I think it's my fault for using a get() in there rather than getting noramlizeCreateResponse working. Will address.

Hi James! That ember-data Identity RFC I linked above has been implemented. I have not had a chance to review it yet, but I wanted to make you aware of it; I would expect that update directly affects this issue and a better solution may be available now.

If it is helpful I could review the new Identifiers and update this issue..?

Thanks for pointing that out, I was playing around with the new stable id and it's very helpful. It will make some of the fastboot stuff I want to accomplish far easier. In the meantime I was able to use _internalRecord.setId in the createRecord method along with a a noop normalizeCreateRecordResponse to solve it.

Closed in aa9a8d0

There should be a new release cut by the CI/CD system to emberfire@canary shortly. I have a couple things I want to fix up before the next RC.

Thanks again for making my job easier here!

rc.4 shipped.