Облачная функция для тайм-аута Firebase через 60 секунд при выполнении нескольких запросов Firebase

Я использую Firebase для приложения для групповой совместной работы (например, Whatsapp) и использую облачную функцию, чтобы выяснить, какие из телефонных контактов также используют мое приложение (опять же аналогично Whatsapp). Облачная функция работала нормально, пока я не начал видеть следующий журнал в журнале функций для некоторых вызовов.

Function execution took 60023 ms, finished with status: 'timeout'

Я провел некоторую отладку и обнаружил, что для этого конкретного пользователя у него есть много контактов в книге контактов его телефона, и поэтому очевидно, что работа, необходимая для выяснения того, какие из этих контактов используют приложение, также увеличилась до такой степени, что потребовалось более 60 сек. Ниже приведен код облачной функции.

      // contactsData is an array of contacts on the user's phone
      // Each contact can contain one more phone numbers which are
      // present in the phoneNumbers array. So, essentially, we need
      // to query over all the phone numbers in the user's contact book
      contactsData.forEach((contact) => {
        contact.phoneNumbers.forEach((phoneNumber) => {
          // Find if user with this phoneNumber is using the app
          // Check against mobileNumber and mobileNumberWithCC
          promises.push(ref.child('users').orderByChild("mobileNumber").
            equalTo(phoneNumber.number).once("value").then(usersSnapshot => {
              // usersSnapshot should contain just one entry assuming
              // that the phoneNumber will be unique to the user
              if(!usersSnapshot.exists()) {
                return null
              }
              var user = null
              usersSnapshot.forEach(userSnapshot => {
                user = userSnapshot.val()
              })
              return {
                name: contact.name,
                mobileNumber: phoneNumber.number,
                id: user.id
              }
            }))
          promises.push(ref.child('users').orderByChild("mobileNumberWithCC").
            equalTo(phoneNumber.number).once("value").then(usersSnapshot => {
              // usersSnapshot should contain just one entry assuming
              // that the phoneNumber will be unique to the user
              if(!usersSnapshot.exists()) {
                return null
              }
              var user = null
              usersSnapshot.forEach(userSnapshot => {
                user = userSnapshot.val()
              })
              return {
                name: contact.name,
                mobileNumber: phoneNumber.number,
                id: user.id
              }
            }))
        });
      });
      return Promise.all(promises)
    }).then(allContacts => {
      // allContacts is an array of nulls and contacts using the app
      // Get rid of null and any duplicate entries in the returned array
      currentContacts = arrayCompact(allContacts)

      // Create contactsObj which will the user's contacts that are using the app
      currentContacts.forEach(contact => {
        contactsObj[contact.id] = contact
      })
      // Return the currently present contacts
      return ref.child('userInfos').child(uid).child('contacts').once('value')
    }).then((contactsSnapshot) => {
      if(contactsSnapshot.exists()) {
        contactsSnapshot.forEach((contactSnapshot) => {
          previousContacts.push(contactSnapshot.val())
        })
      }
      // Update the contacts on firease asap after reading the previous contacts
      ref.child('userInfos').child(uid).child('contacts').set(contactsObj)

      // Figure out the new, deleted and renamed contacts
      newContacts = arrayDifferenceWith(currentContacts, previousContacts, 
        (obj1, obj2) => (obj1.id === obj2.id))
      deletedContacts = arrayDifferenceWith(previousContacts, currentContacts,
        (obj1, obj2) => (obj1.id === obj2.id))
      renamedContacts = arrayIntersectionWith(currentContacts, previousContacts,
        (obj1, obj2) => (obj1.id === obj2.id && obj1.name !== obj2.name))
      // Create the deletedContactsObj to store on firebase
      deletedContacts.forEach((deletedContact) => {
        deletedContactsObj[deletedContact.id] = deletedContact
      })
      // Get the deleted contacts
      return ref.child('userInfos').child(uid).child('deletedContacts').once('value')
    }).then((deletedContactsSnapshot) => {
      if(deletedContactsSnapshot.exists()) {
        deletedContactsSnapshot.forEach((deletedContactSnapshot) => {
          previouslyDeletedContacts.push(deletedContactSnapshot.val())
        })
      }
      // Contacts that were previously deleted but now added again
      restoredContacts = arrayIntersectionWith(newContacts, previouslyDeletedContacts,
        (obj1, obj2) => (obj1.id === obj2.id))
      // Removed the restored contacts from the deletedContacts
      restoredContacts.forEach((restoredContact) => {
        deletedContactsObj[restoredContact.id] = null
      })
      // Update groups using any of the deleted, new or renamed contacts
      return ContactsHelper.processContactsData(uid, deletedContacts, newContacts, renamedContacts)
    }).then(() => {
      // Set after retrieving the previously deletedContacts
      return ref.child('userInfos').child(uid).child('deletedContacts').update(deletedContactsObj)
    })

Ниже приведены некоторые примеры данных

// This is a sample contactsData
[
  {
    "phoneNumbers": [
      {
        "number": "12324312321",
        "label": "home"
      },
      {
        "number": "2322412132",
        "label": "work"
      }
    ],
    "givenName": "blah5",
    "familyName": "",
    "middleName": ""
  },
  {
    "phoneNumbers": [
      {
        "number": "1231221221",
        "label": "mobile"
      }
    ],
    "givenName": "blah3",
    "familyName": "blah4",
    "middleName": ""
  },
  {
    "phoneNumbers": [
      {
        "number": "1234567890",
        "label": "mobile"
      }
    ],
    "givenName": "blah1",
    "familyName": "blah2",
    "middleName": ""
  }
]



// This is how users are stored on Firebase. This could a lot of users
  "users": {
    "id1" : {
      "countryCode" : "91",
      "id" : "id1",
      "mobileNumber" : "1231211232",
      "mobileNumberWithCC" : "911231211232",
      "name" : "Varun"
    },
    "id2" : {
      "countryCode" : "1",
      "id" : "id2",
      "mobileNumber" : "2342112133",
      "mobileNumberWithCC" : "12342112133",
      "name" : "Ashish"
    },
    "id3" : {
      "countryCode" : "1",
      "id" : "id3",
      "mobileNumber" : "123213421",
      "mobileNumberWithCC" : "1123213421",
      "name" : "Pradeep Singh"
    }
  }

В этом конкретном случае contactsData содержало 1046 записей, а для некоторых из этих записей было два phoneNumbers. Итак, давайте предположим, что мне нужно проверить 1500 телефонных номеров. Я создаю запросы для сравнения с mobileNumber и mobileNumberWithCC для пользователей в базе данных. Итак, есть в общей сложности 3000 запросов, которые функция выполнит до того, как обещание завершится, и я предполагаю, что для завершения всех этих запросов требуется более 60 секунд, и, следовательно, время ожидания облачной функции истекло.

Мои несколько вопросов:

  1. Ожидается ли, что все эти запросы займут более 60 секунд? Я ожидал, что он завершится намного быстрее, учитывая, что он работает в инфраструктуре Firebase.
  2. Есть ли способ увеличить лимит времени ожидания для функции? В настоящее время я нахожусь на плане Blaze.

Я также буду признателен за любые альтернативные предложения по реализации вышеуказанной функции, чтобы облегчить проблему. Спасибо!


person Varun Gupta    schedule 05.04.2017    source источник
comment
Я в замешательстве. Вы запрашиваете все эти данные, но ничего с ними не делаете. Все, что я вижу, это то, что вы возвращаете объект из then(). Обычно вы должны вернуть другое обещание из then() или фактически использовать его в цепочке then().   -  person Doug Stevenson    schedule 05.04.2017
comment
@DougStevenson Я пропустил этот фрагмент кода, чтобы упростить его. По сути, приведенный выше код вернет мне массив контактов, которые используют мое приложение. Я загружаю эти контакты в Firebase, который считывается приложением, чтобы заполнить список контактов с помощью приложения. Я также выполняю другие действия, например, выясняю все удаленные, восстановленные и переименованные контакты с момента последней синхронизации, а затем обновляю группы, которые используют такие контакты в приложении. Я немного обновил код, чтобы отразить это. Судя по моим записям, время ожидания функции истекает до завершения вызова Promise.all.   -  person Varun Gupta    schedule 05.04.2017
comment
Хорошо, запрос тысяч элементов в любом случае кажется излишним, особенно если вы собираетесь использовать только один элемент. Вы должны найти способ структурировать свои данные и запрос, чтобы возвращать только несколько элементов, которые вам нужны в начале.   -  person Doug Stevenson    schedule 05.04.2017
comment
@DougStevenson Я не понимаю, почему вы думаете, что я собираюсь использовать один предмет. Меня интересует результат всех этих тысяч запросов. Например. скажем, у меня есть 100 000 пользователей, использующих мое приложение, которое я храню в ветке users базы данных, и я храню очень минимальную информацию о каждом пользователе. Теперь пользователь запускает приложение, и у него есть 1000 контактов в этой книге контактов, и мне нужно выяснить, какие из этих 1000 контактов используют приложение. Итак, мне нужно будет выполнить как минимум 1000 запросов. Если все эти 1000 контактов используют приложение, я буду использовать результат всех этих запросов.   -  person Varun Gupta    schedule 06.04.2017
comment
В этом коде: usersSnapshot.forEach(userSnapshot => { user = userSnapshot.val() }) вы повторяете все в usersSnapshot, но запоминаете только одного пользователя из этой коллекции, независимо от того, что было последним. Может быть, было бы полезно, если бы вы добавили несколько комментариев о том, чего вы пытаетесь достичь на каждом этапе, вместе с образцом некоторых данных, потому что сложно представить, что вы все здесь структурировали.   -  person Doug Stevenson    schedule 06.04.2017
comment
@DougStevenson Я ожидаю, что у usersSnapshot будет только один пользователь. Я перебираю contactsData, который представляет собой массив телефонных контактов. У каждого контакта может быть несколько телефонных номеров, но обычно только один. Итак, зацикливаем каждый номер телефона в контакте. Затем я запускаю ref.child('users').orderByChild("mobileNumber").equalTo(phoneNumber.number).once("value") запрос, чтобы проверить, использует ли пользователь с этим номером телефона приложение, которое должно быть только одним пользователем. Поскольку он будет возвращен как DataSnapshot, а мне нужна фактическая запись пользователя, я делаю forEach. 1/2   -  person Varun Gupta    schedule 06.04.2017
comment
@DougStevenson Я также могу сделать здесь usersSnapshot.val() и получить первый результат, если это ускорит запрос. Я добавил еще несколько комментариев к коду. Я также добавлю некоторые примеры данных. 2/2   -  person Varun Gupta    schedule 06.04.2017


Ответы (2)


Если вы не можете избежать запроса такого большого количества данных, вы можете изменить время ожидания функции в Cloud Console для свой проект с помощью продукта «Функции» слева. В настоящее время вам придется сбрасывать тайм-аут при каждом новом развертывании.

person Doug Stevenson    schedule 06.04.2017
comment
Спасибо за ответ. Это решит мою проблему на данный момент. Я буду очень рад вашим комментариям о том, как я могу структурировать и запрашивать свои данные для этого рабочего процесса. Мой рабочий процесс довольно прост. У меня есть список пользователей, использующих мое приложение в Firebase. Каждый пользователь содержит номер мобильного телефона. Для данного массива мобильных номеров я хочу узнать, какие из этих мобильных номеров используют приложение. Еще раз спасибо за ваши комментарии и ответы. Теперь я могу выпустить новую версию своего приложения, используя облачные функции вместо работника firebase-queue :) - person Varun Gupta; 06.04.2017
comment
Итак, получилось сочетание таймаута и памяти, выделенной функции. Первоначально я просто изменил время на 120 секунд, но время ожидания все равно истекло. Затем я увеличил выделенную память с 256 МБ до 512 МБ, после чего функция завершилась за 64 секунды. Не уверен, следует ли мне увеличить выделенную память и как это повлияет на стоимость и время, необходимое для завершения функции, но я проведу несколько тестов. Спасибо! - person Varun Gupta; 06.04.2017
comment
Вы определенно платите за сочетание времени и памяти, когда используете план Blaze, согласно странице с ценами. - person Doug Stevenson; 06.04.2017
comment
Да, вот о чем я думал, что если я просто увеличу память, выделенную для функции, скажем, до 1 ГБ, то это также повлияет на цену вызовов той же функции, которые завершаются менее чем за 100 мс, что составляет 95% призывы. Я не думаю, что увеличенная память пропорционально повлияет на время, необходимое для завершения этих вызовов. Я проведу несколько тестов в отношении этого и попытаюсь найти правильную комбинацию. - person Varun Gupta; 06.04.2017

Эта проблема

Ваша проблема с производительностью связана с запросом ref.child('users').orderByChild("mobileNumber").equalTo(phon‌​eNumber.number).once‌​("value"), который вы вызываете из forEach() внутри другого forEach().

Чтобы разбить этот запрос, вы, по сути, просите базу данных перебрать дочерние элементы /users, сравнить ключи mobileNumber и phon‌​eNumber.number и, если они совпадают, вернуть значение. Однако вы вызываете это не только для mobileNumber и mobileNumberWithCC, но и для каждой итерации forEach(). Таким образом, это означает, что вы просматриваете X количество пользователей, Y количество телефонных номеров, Z количество контактов, поэтому выполняете до X*Y*Z внутренних операций с базой данных. Это, очевидно, утомительно, и поэтому ваш запрос обрабатывается более 60 секунд.

Возможное исправление

Я бы порекомендовал реализовать в вашей базе данных индекс под названием /phoneNumbers. Каждый ключ в /phoneNumbers должен называться n########### или c########### и содержать «массив» идентификаторов пользователей, связанных с этим номером телефона.

Эта структура будет выглядеть примерно так:

"phoneNumbers": {
  "n1234567890": { // without CC, be warned of overlap
    "userId1": true,
    "userId3": true
  },
  "c011234567890": { // with CC for US
    "userId1": true
  },
  "c611234567890": { // with CC for AU
    "userId3": true
  },
  ...
}

Примечания:

Почему номера телефонов хранятся в формате n########### и c###########?

Это связано с тем, что Firebase рассматривает числовые ключи как индексы массива. Это не имеет смысла для данного варианта использования, поэтому мы добавляем n/c в начале, чтобы подавить такое поведение.

Зачем использовать и n###########, и c###########?

Если бы во всех записях использовался просто префикс n, 11-значный номер телефона мог бы совпадать с 10-значным номером телефона, к которому добавлен код страны. Поэтому мы используем n для обычных телефонных номеров и c для номеров, которые включают код страны.

Почему вы сказали, что каждый ключ /phoneNumbers содержит "массив" идентификаторов пользователей?

Это связано с тем, что вам следует избегать использования массивов числовых индексов в базах данных Firebase (и массивов вообще). Допустим, два отдельных процесса хотели обновить /phoneNumbers/n1234567890, удалив идентификаторы пользователей. Если один хочет удалить идентификатор в позиции 1, а другой — в позиции 2; вместо этого они удалят идентификаторы в позициях 1 и 3. Это можно преодолеть, сохранив идентификатор пользователя в качестве ключа, что позволяет добавлять/удалять его по идентификатору, а не по позиции.

Реализация

Поскольку вы уже используете облачные функции, реализовать такой индекс относительно просто. Этот код можно легко адаптировать для любого типа автоматически сгенерированного индекса на основе пользовательских данных.

// Initialize functions and admin.
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);

/**
 * Listens to operations on the children of `/users` and updates the `/phoneNumbers` index appropriately.
 */
exports.handleNewUser = functions.database.ref('/users/{userId}')
  .onWrite(event => {
    var deltaSnapshot = event.data,
        userId = event.params.userId,
        tasks = []; // for returned promises

    if (!deltaSnapshot.exists()) {
      // This user has been deleted.
      var previousData = deltaSnapshot.previous.val();
      if (previousData.number) {
        tasks.push(removeUserFromPhoneNumber(userId, previousData.number, false));
      }
      if (previousData.numberWithCC) {
        tasks.push(removeUserFromPhoneNumber(userId, previousData.numberWithCC, true));
      }
      // Handle other cleanup tasks.
      return Promise.all(tasks).then(() => {
        console.log('User "' + userId + '" deleted successfully.');
      });
    }

    var currentData = deltaSnapshot.val();

    if (deltaSnapshot.previous.exists()) {
      // This is an update to existing data.
      var previousData = deltaSnapshot.previous.val();

      if (currentData.number != previousData.number) { // Phone number changed.
        tasks.push(removeUserFromPhoneNumber(userId, previousData.number, false));
        tasks.push(addUserToPhoneNumber(userId, currentData.number, false));
      }
      if (currentData.numberWithCC != previousData.numberWithCC) { // Phone number changed.
        tasks.push(removeUserFromPhoneNumber(userId, previousData.numberWithCC, true));
        tasks.push(addUserToPhoneNumber(userId, currentData.numberWithCC, true));
      }
      // Handle other tasks related to update.
      return Promise.all(tasks).then(() => {
        console.log('User "' + userId + '" updated successfully.');
      });
    }

    // If here, this is a new user.
    tasks.push(addUserToPhoneNumber(userId, currentData.number, false));
    tasks.push(addUserToPhoneNumber(userId, currentData.numberWithCC, true));
    // Handle other tasks related to addition of new user.
    return Promise.all(tasks).then(() => {
      console.log('User "' + userId + '" created successfully.');
    });
  );

/* Phone Number Index Helper Functions */

/**
 * Returns an array of user IDs linked to the specified phone number.
 * @param {String} number - the phone number
 * @param {Boolean} withCountryCode - true, if the phone number includes a country code
 * @return {Promise} - a promise returning an array of user IDs, may be empty.
 */
function lookupUsersByPhoneNumber(number, withCountryCode) {
  // Error out before corrupting data.
  if (!number) return Promise.reject(new TypeError('number cannot be falsy.');
  return lookupIdsByIndex('phoneNumbers', (withCountryCode ? 'c' : 'n') + number);
}

/**
 * Adds the user ID under the specified phone number's index.
 * @param {String} userId - the user ID
 * @param {String} number - the phone number
 * @param {Boolean} withCountryCode - true, if the phone number includes a country code
 * @return {Promise} - the promise returned by transaction()
 */
function addUserToPhoneNumber(userId, number, withCountryCode) {
  // Error out before corrupting data.
  if (!number) return Promise.reject(new TypeError('number cannot be falsy.');
  return addIdToIndex(userId, 'phoneNumbers', (withCountryCode ? 'c' : 'n') + number)
}

/**
 * Removes the user ID under the specified phone number's index.
 * @param {String} userId - the user ID
 * @param {String} number - the phone number
 * @param {Boolean} withCountryCode - true, if the phone number includes a country code
 * @return {Promise} - the promise returned by transaction()
 */
function removeUserFromPhoneNumber(userId, number, withCountryCode) {
  // Error out before corrupting data.
  if (!number) return Promise.reject(new TypeError('number cannot be falsy.');
  return removeIdFromIndex(userId, 'phoneNumbers', (withCountryCode ? 'c' : 'n') + number)
}

/* General Firebase Index CRUD APIs */
/* Credit: @samthecodingman */

/**
 * Returns an array of IDs linked to the specified key in the given index.
 * @param {String} indexName - the index name
 * @param {String} keyName - the key name
 * @return {Promise} - the promise returned by transaction()
 */
function lookupIdsByIndex(indexName, keyName) {
  // Error out before corrupting data.
  if (!indexName) return Promise.reject(new TypeError('indexName cannot be falsy.');
  if (!keyName) return Promise.reject(new TypeError('keyName cannot be falsy.');
  return admin.database().ref(indexName).child(keyName).once("value")
  .then(snapshot => {
    if (!snapshot.exists()) return []; // Use empty array for 'no data'
    var idsObject = snapshot.val();
    if (idsObject == null) return [];
    return Object.keys(idsObject); // return array of IDs
  });
}

/**
 * Adds the ID to the index under the named key.
 * @param {String} id - the entry ID
 * @param {String} indexName - the index name
 * @param {String} keyName - the key name
 * @return {Promise} - the promise returned by transaction()
 */
function addIdToIndex(id, indexName, keyName) {
  // Error out before corrupting data.
  if (!id) return Promise.reject(new TypeError('id cannot be falsy.');
  if (!indexName) return Promise.reject(new TypeError('indexName cannot be falsy.');
  if (!keyName) return Promise.reject(new TypeError('keyName cannot be falsy.');
  return admin.database().ref(indexName).child(keyName)
  .transaction(function(idsObject) {
    idsObject = idsObject || {}; // Create data if it doesn't exist.
    if (idsObject.hasOwnProperty(id)) return; // No update needed.
    idsObject[id] = true; // Add ID.
    return idsObject;
  });
}

/**
 * Removes the ID from the index under the named key.
 * @param {String} id - the entry ID
 * @param {String} indexName - the index name
 * @param {String} keyName - the key name
 * @return {Promise} - the promise returned by transaction()
 */
function removeIdFromIndex(id, indexName, keyName) {
  // Error out before corrupting data.
  if (!id) return Promise.reject(new TypeError('id cannot be falsy.');
  if (!indexName) return Promise.reject(new TypeError('indexName cannot be falsy.');
  if (!keyName) return Promise.reject(new TypeError('keyName cannot be falsy.');
  return admin.database().ref(indexName).child(keyName)
  .transaction(function(idsObject) {
    if (idsObject === null) return; // No data to update.
    if (!idsObject.hasOwnProperty(id)) return; // No update needed.
    delete idsObject[id]; // Remove ID.
    if (Object.keys(idsObject).length === 0) return null; // Delete entire entry.
    return idsObject;
  });
}

Функция handleNewUser в приведенном выше фрагменте не отлавливает ошибки. Это просто позволит Firebase разобраться с ними (по умолчанию FB просто регистрирует ошибку). Я бы порекомендовал реализовать соответствующие запасные варианты по вашему желанию (как и в случае с любой облачной функцией).

Что касается исходного кода в вашем вопросе, он станет чем-то похожим на:

contactsData.forEach((contact) => {
  contact.phoneNumbers.forEach((phoneNumber) => {
    var tasks = [];
    tasks.push(lookupUsersByPhoneNumber(phoneNumber.number, false)); // Lookup without CC
    tasks.push(lookupUsersByPhoneNumber(phoneNumber.number, true)); // Lookup with CC
    Promise.all(tasks).then(taskResults => {
      var i = 0;
      // Elements of taskResults are arrays of strings from the lookup functions.
      // Flatten and dedupe strings arrays
      var userIds = taskResults.reduce((arr, results) => {
        for (i=0;i<results.length;i++) {
          if (results[i] !== null && ~arr.indexOf(results[i])) {
            arr.push(results[i]); // Add if not already added.
          }              
        }
        return arr;
      }, []);

      // Build 'contacts' array (Doesn't need a database lookup!)
      return userIds.map(uid => ({
        name: contact.name,
        phone: phoneNumber.number,
        id: uid
      }));
    }).then(currentContacts => {
      currentContacts.forEach(contact => {
        contactsObj[contact.id] = contact
      });

      // do original code from question here.
      // I'm not 100% on what it does, so I'll leave it to you.
      // It currently uses an array which is a bad implementation (see notes above). Use PUSH to update the contacts rather than deleting and readding them constantly.
    });
  });
});

Примечание о безопасности/конфиденциальности

Я бы настоятельно рекомендовал ограничить доступ на чтение и запись /phoneNumbers только работнику службы облачных функций из соображений конфиденциальности. Это также может потребовать переноса частей логики вашей программы на сервер в зависимости от проблем с разрешениями.

Для этого замените:

admin.initializeApp(functions.config().firebase);

с:

admin.initializeApp(Object.assign({}, functions.config().firebase, {
  databaseAuthVariableOverride: {
    uid: "cloudfunc-service-worker" // change as desired
  }
});

и чтобы включить его, вам необходимо настроить правила базы данных Firebase следующим образом:

"rules": {
    "phoneNumbers": {
      ".read": "'cloudfunc-service-worker' === auth.uid",
      ".write": "'cloudfunc-service-worker' === auth.uid"
    }
  }
person samthecodingman    schedule 07.07.2017
comment
Спасибо @samthecodingman за расширенный ответ. Я рассмотрю ответ более подробно, чтобы понять его полностью, и опубликую комментарии, если они у меня есть. - person Varun Gupta; 08.07.2017