Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add "on backgrounded" function #611

Merged
merged 29 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
966a0db
feat: add "on backgrounded" function
EvanHahn Apr 25, 2024
2cb7a0b
Merge branch 'main' into evanhahn/576
EvanHahn May 7, 2024
0dd8afd
Merge branch 'main' into evanhahn/576
EvanHahn May 8, 2024
e2fb950
Merge branch 'main' into evanhahn/576
EvanHahn May 9, 2024
0666dc7
Merge branch 'main' into evanhahn/576
EvanHahn May 13, 2024
9b7470d
Add auto-stop functionality
EvanHahn May 15, 2024
610d359
Simplify autostop
EvanHahn May 15, 2024
93b697c
Merge branch 'main' into evanhahn/576
EvanHahn May 15, 2024
b8c5d7d
Rename "namespace group" to "sync enabled state"
EvanHahn May 16, 2024
f427f64
Rename "initial" to "presync"
EvanHahn May 16, 2024
072a2b6
Simplify background/foreground API with two booleans
EvanHahn May 16, 2024
2e5fcb7
Merge branch 'main' into evanhahn/576
EvanHahn May 16, 2024
8a9eb15
Minor optimization when changing `syncEnabledState`
EvanHahn May 20, 2024
c629505
test: move FastifyController tests to `node:test` (#643)
EvanHahn May 16, 2024
eb3192c
test: move basic manager tests to `node:test` (#662)
EvanHahn May 20, 2024
2058116
test: move Fastify server tests to `node:test` (#661)
EvanHahn May 20, 2024
6fdc9f3
test: move project settings tests to `node:test` (#660)
EvanHahn May 20, 2024
2b6eb06
chore: add Node 20 to CI (#652)
achou11 May 20, 2024
660027a
test: remove `t.plan()` calls (#658)
EvanHahn May 20, 2024
2126de6
feat: delete lots of data from indexers when leaving project (#469)
EvanHahn May 20, 2024
ba539cf
Move "previous sync enabled state" to `SyncApi`
EvanHahn May 20, 2024
4a641e7
Rename "is backgrounded" to "has requested full stop"
EvanHahn May 20, 2024
8fe34aa
Rename syncing to isSyncEnabled
EvanHahn May 20, 2024
dc9e5c1
Merge branch 'main' into evanhahn/576
EvanHahn May 20, 2024
db4ff6c
Add default param for #updateState
EvanHahn May 20, 2024
a71a149
Merge branch 'main' into evanhahn/576
EvanHahn May 21, 2024
f649a2b
Remove accidentally-committed jscodeshift file
EvanHahn May 21, 2024
c437176
Merge branch 'main' into evanhahn/576
EvanHahn May 22, 2024
9f43b1c
Merge branch 'main' into evanhahn/576
EvanHahn May 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
EvanHahn marked this conversation as resolved.
Show resolved Hide resolved
/** @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
EvanHahn marked this conversation as resolved.
Show resolved Hide resolved
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) {
EvanHahn marked this conversation as resolved.
Show resolved Hide resolved
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
Loading