Skip to content

Commit

Permalink
feat: add "on backgrounded" function (#611)
Browse files Browse the repository at this point in the history
`MapeoManager#onBackgrounded()` should be called when the app goes into
the background. It will do its best to gracefully shut down sync.

Closes [#576].

[#576]: #576

Co-authored-by: Gregor MacLennan <gmaclennan@digital-democracy.org>
  • Loading branch information
EvanHahn and gmaclennan committed May 27, 2024
1 parent 9669265 commit bb9b16c
Show file tree
Hide file tree
Showing 4 changed files with 275 additions and 59 deletions.
32 changes: 31 additions & 1 deletion src/mapeo-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ import { LocalDiscovery } from './discovery/local-discovery.js'
import { Roles } from './roles.js'
import NoiseSecretStream from '@hyperswarm/secret-stream'
import { Logger } from './logger.js'
import { kSyncState } from './sync/sync-api.js'
import {
kSyncState,
kRequestFullStop,
kRescindFullStopRequest,
} from './sync/sync-api.js'

/** @typedef {import("@mapeo/schema").ProjectSettingsValue} ProjectValue */
/** @typedef {import('type-fest').SetNonNullable<ProjectKeys, 'encryptionKeys'>} ValidatedProjectKeys */
Expand Down Expand Up @@ -749,6 +753,32 @@ export class MapeoManager extends TypedEmitter {
return omitPeerProtomux(this.#localPeers.peers)
}

/**
* Call this when the app goes into the background.
*
* Will gracefully shut down sync.
*
* @see {@link onForegrounded}
* @returns {void}
*/
onBackgrounded() {
const projects = this.#activeProjects.values()
for (const project of projects) project.$sync[kRequestFullStop]()
}

/**
* Call this when the app goes into the foreground.
*
* Will undo the effects of `onBackgrounded`.
*
* @see {@link onBackgrounded}
* @returns {void}
*/
onForegrounded() {
const projects = this.#activeProjects.values()
for (const project of projects) project.$sync[kRescindFullStopRequest]()
}

/**
* @param {string} projectPublicId
*/
Expand Down
75 changes: 49 additions & 26 deletions src/sync/peer-sync-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export const DATA_NAMESPACES = NAMESPACES.filter(
(ns) => !PRESYNC_NAMESPACES.includes(ns)
)

/**
* @internal
* @typedef {import('./sync-api.js').SyncEnabledState} SyncEnabledState
*/

export class PeerSyncController {
#replicatingCores = new Set()
/** @type {Set<Namespace>} */
Expand All @@ -25,7 +30,8 @@ export class PeerSyncController {
#roles
/** @type {Record<Namespace, SyncCapability>} */
#syncCapability = createNamespaceMap('unknown')
#isDataSyncEnabled = false
/** @type {SyncEnabledState} */
#syncEnabledState = 'none'
/** @type {Record<Namespace, import('./core-sync-state.js').CoreState | null>} */
#prevLocalState = createNamespaceMap(null)
/** @type {SyncStatus} */
Expand Down Expand Up @@ -83,22 +89,12 @@ export class PeerSyncController {
return this.#syncCapability
}

/**
* Enable syncing of data (in the data and blob namespaces)
*/
enableDataSync() {
this.#isDataSyncEnabled = true
this.#updateEnabledNamespaces()
}

/**
* Disable syncing of data (in the data and blob namespaces).
*
* Syncing of metadata (auth, config and blobIndex namespaces) will continue
* in the background without user interaction.
*/
disableDataSync() {
this.#isDataSyncEnabled = false
/** @param {SyncEnabledState} syncEnabledState */
setSyncEnabledState(syncEnabledState) {
if (this.#syncEnabledState === syncEnabledState) {
return
}
this.#syncEnabledState = syncEnabledState
this.#updateEnabledNamespaces()
}

Expand Down Expand Up @@ -184,18 +180,45 @@ export class PeerSyncController {
}
this.#log('capability %o', this.#syncCapability)

// If any namespace has new data, update what is enabled
if (Object.values(didUpdate).indexOf(true) > -1) {
this.#updateEnabledNamespaces()
}
this.#updateEnabledNamespaces()
}

/**
* Enable and disable the appropriate namespaces.
*
* If replicating no namespace groups, all namespaces are disabled.
*
* If only replicating the initial namespace groups, only the initial
* namespaces are replicated, assuming the capability permits.
*
* If replicating all namespaces, everything is replicated. However, data
* namespaces are only enabled after the initial namespaces have synced. And
* again, capabilities are checked.
*/
#updateEnabledNamespaces() {
// - If the sync capability is unknown, then the namespace is disabled,
// apart from the auth namespace.
// - If sync capability is allowed, the "pre-sync" namespaces are enabled,
// and if data sync is enabled, then all namespaces are enabled
/** @type {boolean} */ let isAnySyncEnabled
/** @type {boolean} */ let isDataSyncEnabled
switch (this.#syncEnabledState) {
case 'none':
isAnySyncEnabled = isDataSyncEnabled = false
break
case 'presync':
isAnySyncEnabled = true
isDataSyncEnabled = false
break
case 'all':
isAnySyncEnabled = isDataSyncEnabled = true
break
default:
throw new ExhaustivenessError(this.#syncEnabledState)
}

for (const ns of NAMESPACES) {
if (!isAnySyncEnabled) {
this.#disableNamespace(ns)
continue
}

const cap = this.#syncCapability[ns]
if (cap === 'blocked') {
this.#disableNamespace(ns)
Expand All @@ -208,7 +231,7 @@ export class PeerSyncController {
} else if (cap === 'allowed') {
if (PRESYNC_NAMESPACES.includes(ns)) {
this.#enableNamespace(ns)
} else if (this.#isDataSyncEnabled) {
} else if (isDataSyncEnabled) {
const arePresyncNamespacesSynced = PRESYNC_NAMESPACES.every(
(ns) => this.#syncStatus[ns] === 'synced'
)
Expand Down
126 changes: 95 additions & 31 deletions src/sync/sync-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,29 @@ import {
} from './peer-sync-controller.js'
import { Logger } from '../logger.js'
import { NAMESPACES } from '../constants.js'
import { keyToId } from '../utils.js'
import { ExhaustivenessError, keyToId } from '../utils.js'

export const kHandleDiscoveryKey = Symbol('handle discovery key')
export const kSyncState = Symbol('sync state')
export const kRequestFullStop = Symbol('background')
export const kRescindFullStopRequest = Symbol('foreground')

/**
* @typedef {'initial' | 'full'} SyncType
*/

/**
* @typedef {'none' | 'presync' | 'all'} SyncEnabledState
*/

/**
* @typedef {object} SyncTypeState
* @property {number} have Number of blocks we have locally
* @property {number} want Number of blocks we want from connected peers
* @property {number} wanted Number of blocks that connected peers want from us
* @property {number} missing Number of blocks missing (we don't have them, but connected peers don't have them either)
* @property {boolean} dataToSync Is there data available to sync? (want > 0 || wanted > 0)
* @property {boolean} syncing Are we currently syncing?
* @property {boolean} isSyncEnabled Do we want to sync this type of data?
*/

/**
Expand All @@ -50,7 +56,10 @@ export class SyncApi extends TypedEmitter {
#pscByPeerId = new Map()
/** @type {Set<string>} */
#peerIds = new Set()
#isSyncing = false
#wantsToSyncData = false
#hasRequestedFullStop = false
/** @type {SyncEnabledState} */
#previousSyncEnabledState = 'none'
/** @type {Map<import('protomux'), Set<Buffer>>} */
#pendingDiscoveryKeys = new Map()
#l
Expand All @@ -75,8 +84,7 @@ export class SyncApi extends TypedEmitter {
})
this[kSyncState].setMaxListeners(0)
this[kSyncState].on('state', (namespaceSyncState) => {
const state = this.#getState(namespaceSyncState)
this.emit('sync-state', state)
this.#updateState(namespaceSyncState)
})

this.#coreManager.creatorCore.on('peer-add', this.#handlePeerAdd)
Expand Down Expand Up @@ -125,34 +133,92 @@ export class SyncApi extends TypedEmitter {
*/
#getState(namespaceSyncState) {
const state = reduceSyncState(namespaceSyncState)
state.data.syncing = this.#isSyncing

switch (this.#previousSyncEnabledState) {
case 'none':
state.initial.isSyncEnabled = state.data.isSyncEnabled = false
break
case 'presync':
state.initial.isSyncEnabled = true
state.data.isSyncEnabled = false
break
case 'all':
state.initial.isSyncEnabled = state.data.isSyncEnabled = true
break
default:
throw new ExhaustivenessError(this.#previousSyncEnabledState)
}

return state
}

#updateState(namespaceSyncState = this[kSyncState].getState()) {
/** @type {SyncEnabledState} */ let syncEnabledState
if (this.#hasRequestedFullStop) {
if (this.#previousSyncEnabledState === 'none') {
syncEnabledState = 'none'
} else if (
isSynced(
namespaceSyncState,
this.#wantsToSyncData ? 'full' : 'initial',
this.#peerSyncControllers
)
) {
syncEnabledState = 'none'
} else if (this.#wantsToSyncData) {
syncEnabledState = 'all'
} else {
syncEnabledState = 'presync'
}
} else {
syncEnabledState = this.#wantsToSyncData ? 'all' : 'presync'
}

this.#l.log(`Setting sync enabled state to "${syncEnabledState}"`)
for (const peerSyncController of this.#peerSyncControllers.values()) {
peerSyncController.setSyncEnabledState(syncEnabledState)
}

this.emit('sync-state', this.#getState(namespaceSyncState))

this.#previousSyncEnabledState = syncEnabledState
}

/**
* Start syncing data cores
* Start syncing data cores.
*
* If the app is backgrounded and sync has already completed, this will do
* nothing until the app is foregrounded.
*/
start() {
if (this.#isSyncing) return
this.#isSyncing = true
this.#l.log('Starting data sync')
for (const peerSyncController of this.#peerSyncControllers.values()) {
peerSyncController.enableDataSync()
}
this.emit('sync-state', this.getState())
this.#wantsToSyncData = true
this.#updateState()
}

/**
* Stop syncing data cores (metadata cores will continue syncing in the background)
* Stop syncing data cores.
*
* Pre-sync cores will continue syncing unless the app is backgrounded.
*/
stop() {
if (!this.#isSyncing) return
this.#isSyncing = false
this.#l.log('Stopping data sync')
for (const peerSyncController of this.#peerSyncControllers.values()) {
peerSyncController.disableDataSync()
}
this.emit('sync-state', this.getState())
this.#wantsToSyncData = false
this.#updateState()
}

/**
* Request a graceful stop to all sync.
*/
[kRequestFullStop]() {
this.#hasRequestedFullStop = true
this.#updateState()
}

/**
* Rescind any requests for a full stop.
*/
[kRescindFullStopRequest]() {
this.#hasRequestedFullStop = false
this.#updateState()
}

/**
Expand All @@ -161,12 +227,11 @@ export class SyncApi extends TypedEmitter {
*/
async waitForSync(type) {
const state = this[kSyncState].getState()
const namespaces = type === 'initial' ? PRESYNC_NAMESPACES : NAMESPACES
if (isSynced(state, namespaces, this.#peerSyncControllers)) return
if (isSynced(state, type, this.#peerSyncControllers)) return
return new Promise((res) => {
const _this = this
this[kSyncState].on('state', function onState(state) {
if (!isSynced(state, namespaces, _this.#peerSyncControllers)) return
if (!isSynced(state, type, _this.#peerSyncControllers)) return
_this[kSyncState].off('state', onState)
res()
})
Expand Down Expand Up @@ -206,9 +271,7 @@ export class SyncApi extends TypedEmitter {
// Add peer to all core states (via namespace sync states)
this[kSyncState].addPeer(peerSyncController.peerId)

if (this.#isSyncing) {
peerSyncController.enableDataSync()
}
this.#updateState()

const peerQueue = this.#pendingDiscoveryKeys.get(protomux)
if (peerQueue) {
Expand Down Expand Up @@ -248,10 +311,11 @@ export class SyncApi extends TypedEmitter {
* Is the sync state "synced", e.g. is there nothing left to sync
*
* @param {import('./sync-state.js').State} state
* @param {readonly import('../core-manager/index.js').Namespace[]} namespaces
* @param {SyncType} type
* @param {Map<import('protomux'), PeerSyncController>} peerSyncControllers
*/
function isSynced(state, namespaces, peerSyncControllers) {
function isSynced(state, type, peerSyncControllers) {
const namespaces = type === 'initial' ? PRESYNC_NAMESPACES : NAMESPACES
for (const ns of namespaces) {
if (state[ns].dataToSync) return false
for (const psc of peerSyncControllers.values()) {
Expand Down Expand Up @@ -312,6 +376,6 @@ function createInitialSyncTypeState() {
wanted: 0,
missing: 0,
dataToSync: false,
syncing: true,
isSyncEnabled: true,
}
}
Loading

0 comments on commit bb9b16c

Please sign in to comment.