Push: Cleanup invalid device tokens (#4149)

* Adds collecting invalid / expired device tokens from GCM / APNS

* Cleanup invalid installations based on responses from the adapters

* Adds test for cleanup

* Adds tests for removal
This commit is contained in:
Florent Vilmart
2017-09-12 14:53:05 -04:00
committed by GitHub
parent a1554d04ab
commit 3a17904ce8
3 changed files with 111 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
var PushWorker = require('../src').PushWorker; var PushWorker = require('../src').PushWorker;
var PushUtils = require('../src/Push/utils'); var PushUtils = require('../src/Push/utils');
var Config = require('../src/Config'); var Config = require('../src/Config');
var { pushStatusHandler } = require('../src/StatusHandler');
describe('PushWorker', () => { describe('PushWorker', () => {
it('should run with small batch', (done) => { it('should run with small batch', (done) => {
@@ -156,4 +157,89 @@ describe('PushWorker', () => {
expect(PushUtils.groupByLocaleIdentifier([])).toEqual({default: []}); expect(PushUtils.groupByLocaleIdentifier([])).toEqual({default: []});
}); });
}); });
describe('pushStatus', () => {
it('should remove invalid installations', (done) => {
const config = new Config('test');
const handler = pushStatusHandler(config);
const spy = spyOn(config.database, "update").and.callFake(() => {
return Promise.resolve();
});
handler.trackSent([
{
transmitted: false,
device: {
deviceToken: 1,
deviceType: 'ios',
},
response: { error: 'Unregistered' }
},
{
transmitted: true,
device: {
deviceToken: 10,
deviceType: 'ios',
},
},
{
transmitted: false,
device: {
deviceToken: 2,
deviceType: 'ios',
},
response: { error: 'NotRegistered' }
},
{
transmitted: false,
device: {
deviceToken: 3,
deviceType: 'ios',
},
response: { error: 'InvalidRegistration' }
},
{
transmitted: true,
device: {
deviceToken: 11,
deviceType: 'ios',
},
},
{
transmitted: false,
device: {
deviceToken: 4,
deviceType: 'ios',
},
response: { error: 'InvalidRegistration' }
},
{
transmitted: false,
device: {
deviceToken: 5,
deviceType: 'ios',
},
response: { error: 'InvalidRegistration' }
},
{ // should not be deleted
transmitted: false,
device: {
deviceToken: 101,
deviceType: 'ios',
},
response: { error: 'invalid error...' }
}
], true);
expect(spy).toHaveBeenCalled();
expect(spy.calls.count()).toBe(1);
const lastCall = spy.calls.mostRecent();
expect(lastCall.args[0]).toBe('_Installation');
expect(lastCall.args[1]).toEqual({
deviceToken: { '$in': [1,2,3,4,5] }
});
expect(lastCall.args[2]).toEqual({
deviceToken: { '__op': "Delete" }
});
done();
});
});
}); });

View File

@@ -9,6 +9,7 @@ import { pushStatusHandler } from '../StatusHandler';
import * as utils from './utils'; import * as utils from './utils';
import { ParseMessageQueue } from '../ParseMessageQueue'; import { ParseMessageQueue } from '../ParseMessageQueue';
import { PushQueue } from './PushQueue'; import { PushQueue } from './PushQueue';
import logger from '../logger';
function groupByBadge(installations) { function groupByBadge(installations) {
return installations.reduce((map, installation) => { return installations.reduce((map, installation) => {
@@ -80,6 +81,7 @@ export class PushWorker {
} }
if (!utils.isPushIncrementing(body)) { if (!utils.isPushIncrementing(body)) {
logger.verbose(`Sending push to ${installations.length}`);
return this.adapter.send(body, installations, pushStatus.objectId).then((results) => { return this.adapter.send(body, installations, pushStatus.objectId).then((results) => {
return pushStatus.trackSent(results); return pushStatus.trackSent(results);
}); });

View File

@@ -162,12 +162,13 @@ export function pushStatusHandler(config, objectId = newObjectId(config.objectId
{status: "running", updatedAt: new Date(), count }); {status: "running", updatedAt: new Date(), count });
} }
const trackSent = function(results) { const trackSent = function(results, cleanupInstallations = process.env.PARSE_SERVER_CLEANUP_INVALID_INSTALLATIONS) {
const update = { const update = {
updatedAt: new Date(), updatedAt: new Date(),
numSent: 0, numSent: 0,
numFailed: 0 numFailed: 0
}; };
const devicesToRemove = [];
if (Array.isArray(results)) { if (Array.isArray(results)) {
results = flatten(results); results = flatten(results);
results.reduce((memo, result) => { results.reduce((memo, result) => {
@@ -181,6 +182,18 @@ export function pushStatusHandler(config, objectId = newObjectId(config.objectId
if (result.transmitted) { if (result.transmitted) {
memo.numSent++; memo.numSent++;
} else { } else {
if (result && result.response && result.response.error && result.device && result.device.deviceToken) {
const token = result.device.deviceToken;
const error = result.response.error;
// GCM errors
if (error === 'NotRegistered' || error === 'InvalidRegistration') {
devicesToRemove.push(token);
}
// APNS errors
if (error === 'Unregistered') {
devicesToRemove.push(token);
}
}
memo.numFailed++; memo.numFailed++;
} }
return memo; return memo;
@@ -189,7 +202,7 @@ export function pushStatusHandler(config, objectId = newObjectId(config.objectId
} }
logger.verbose(`_PushStatus ${objectId}: sent push! %d success, %d failures`, update.numSent, update.numFailed); logger.verbose(`_PushStatus ${objectId}: sent push! %d success, %d failures`, update.numSent, update.numFailed);
logger.verbose(`_PushStatus ${objectId}: needs cleanup`, { devicesToRemove });
['numSent', 'numFailed'].forEach((key) => { ['numSent', 'numFailed'].forEach((key) => {
if (update[key] > 0) { if (update[key] > 0) {
update[key] = { update[key] = {
@@ -201,6 +214,14 @@ export function pushStatusHandler(config, objectId = newObjectId(config.objectId
} }
}); });
if (devicesToRemove.length > 0 && cleanupInstallations) {
logger.info(`Removing device tokens on ${devicesToRemove.length} _Installations`);
database.update('_Installation', { deviceToken: { '$in': devicesToRemove }}, { deviceToken: {"__op": "Delete"} }, {
acl: undefined,
many: true
});
}
return handler.update({ objectId }, update).then((res) => { return handler.update({ objectId }, update).then((res) => {
if (res && res.count === 0) { if (res && res.count === 0) {
return this.complete(); return this.complete();