2020-08-02 13:28:50 +02:00
import { Dirnames } from './utils/types' ;
2020-11-05 18:58:23 +02:00
import shim from '../../shim' ;
2020-09-09 00:57:48 +02:00
2021-05-13 18:57:37 +02:00
import JoplinError from '../../JoplinError' ;
2021-01-22 19:41:11 +02:00
import time from '../../time' ;
2020-11-05 18:58:23 +02:00
const { fileExtension , filename } = require ( '../../path-utils' ) ;
2020-08-02 13:28:50 +02:00
export enum LockType {
None = '' ,
Sync = 'sync' ,
Exclusive = 'exclusive' ,
}
export interface Lock {
2020-11-12 21:29:22 +02:00
type : LockType ;
clientType : string ;
clientId : string ;
updatedTime? : number ;
2020-08-02 13:28:50 +02:00
}
interface RefreshTimer {
2020-11-12 21:29:22 +02:00
id : any ;
inProgress : boolean ;
2020-08-02 13:28:50 +02:00
}
interface RefreshTimers {
2020-11-12 21:13:28 +02:00
[ key : string ] : RefreshTimer ;
2020-08-02 13:28:50 +02:00
}
export interface LockHandlerOptions {
2020-11-12 21:29:22 +02:00
autoRefreshInterval? : number ;
lockTtl? : number ;
2020-08-02 13:28:50 +02:00
}
export default class LockHandler {
2020-11-12 21:13:28 +02:00
private api_ : any = null ;
private refreshTimers_ : RefreshTimers = { } ;
private autoRefreshInterval_ : number = 1000 * 60 ;
private lockTtl_ : number = 1000 * 60 * 3 ;
2020-08-02 13:28:50 +02:00
2020-11-12 21:13:28 +02:00
constructor ( api : any , options : LockHandlerOptions = null ) {
2020-08-02 13:28:50 +02:00
if ( ! options ) options = { } ;
this . api_ = api ;
if ( 'lockTtl' in options ) this . lockTtl_ = options . lockTtl ;
if ( 'autoRefreshInterval' in options ) this . autoRefreshInterval_ = options . autoRefreshInterval ;
}
2020-11-12 21:13:28 +02:00
public get lockTtl ( ) : number {
2020-08-02 13:28:50 +02:00
return this . lockTtl_ ;
}
// Should only be done for testing purposes since all clients should
// use the same lock max age.
2020-11-12 21:13:28 +02:00
public set lockTtl ( v : number ) {
2020-08-02 13:28:50 +02:00
this . lockTtl_ = v ;
}
2020-11-12 21:13:28 +02:00
private lockFilename ( lock : Lock ) {
2020-08-02 13:28:50 +02:00
return ` ${ [ lock . type , lock . clientType , lock . clientId ] . join ( '_' ) } .json ` ;
}
2020-11-12 21:13:28 +02:00
private lockTypeFromFilename ( name : string ) : LockType {
2020-08-02 13:28:50 +02:00
const ext = fileExtension ( name ) ;
if ( ext !== 'json' ) return LockType . None ;
if ( name . indexOf ( LockType . Sync ) === 0 ) return LockType . Sync ;
if ( name . indexOf ( LockType . Exclusive ) === 0 ) return LockType . Exclusive ;
return LockType . None ;
}
2020-11-12 21:13:28 +02:00
private lockFilePath ( lock : Lock ) {
2020-08-02 13:28:50 +02:00
return ` ${ Dirnames . Locks } / ${ this . lockFilename ( lock ) } ` ;
}
2020-11-12 21:13:28 +02:00
private lockFileToObject ( file : any ) : Lock {
2020-08-02 13:28:50 +02:00
const p = filename ( file . path ) . split ( '_' ) ;
return {
type : p [ 0 ] ,
clientType : p [ 1 ] ,
clientId : p [ 2 ] ,
updatedTime : file.updated_time ,
} ;
}
2020-11-12 21:13:28 +02:00
async locks ( lockType : LockType = null ) : Promise < Lock [ ] > {
2020-08-02 13:28:50 +02:00
const result = await this . api_ . list ( Dirnames . Locks ) ;
if ( result . hasMore ) throw new Error ( 'hasMore not handled' ) ; // Shouldn't happen anyway
const output = [ ] ;
for ( const file of result . items ) {
const type = this . lockTypeFromFilename ( file . path ) ;
if ( type === LockType . None ) continue ;
if ( lockType && type !== lockType ) continue ;
const lock = this . lockFileToObject ( file ) ;
output . push ( lock ) ;
}
return output ;
}
2020-11-12 21:13:28 +02:00
private lockIsActive ( lock : Lock , currentDate : Date ) : boolean {
2020-09-09 00:57:48 +02:00
return currentDate . getTime ( ) - lock . updatedTime < this . lockTtl ;
2020-08-02 13:28:50 +02:00
}
2020-11-12 21:13:28 +02:00
async hasActiveLock ( lockType : LockType , clientType : string = null , clientId : string = null ) {
2020-08-02 13:28:50 +02:00
const lock = await this . activeLock ( lockType , clientType , clientId ) ;
return ! ! lock ;
}
// Finds if there's an active lock for this clientType and clientId and returns it.
// If clientType and clientId are not specified, returns the first active lock
// of that type instead.
2020-11-12 21:13:28 +02:00
async activeLock ( lockType : LockType , clientType : string = null , clientId : string = null ) {
2020-08-02 13:28:50 +02:00
const locks = await this . locks ( lockType ) ;
2020-09-09 12:39:13 +02:00
const currentDate = await this . api_ . remoteDate ( ) ;
2020-08-02 13:28:50 +02:00
if ( lockType === LockType . Exclusive ) {
const activeLocks = locks
. slice ( )
2020-11-12 21:13:28 +02:00
. filter ( ( lock : Lock ) = > this . lockIsActive ( lock , currentDate ) )
. sort ( ( a : Lock , b : Lock ) = > {
2020-08-02 13:28:50 +02:00
if ( a . updatedTime === b . updatedTime ) {
return a . clientId < b . clientId ? - 1 : + 1 ;
}
return a . updatedTime < b . updatedTime ? - 1 : + 1 ;
} ) ;
if ( ! activeLocks . length ) return null ;
const activeLock = activeLocks [ 0 ] ;
if ( clientType && clientType !== activeLock . clientType ) return null ;
if ( clientId && clientId !== activeLock . clientId ) return null ;
return activeLock ;
} else if ( lockType === LockType . Sync ) {
for ( const lock of locks ) {
if ( clientType && lock . clientType !== clientType ) continue ;
if ( clientId && lock . clientId !== clientId ) continue ;
2020-09-09 00:57:48 +02:00
if ( this . lockIsActive ( lock , currentDate ) ) return lock ;
2020-08-02 13:28:50 +02:00
}
return null ;
}
throw new Error ( ` Unsupported lock type: ${ lockType } ` ) ;
}
2020-11-12 21:13:28 +02:00
private async saveLock ( lock : Lock ) {
2020-08-02 13:28:50 +02:00
await this . api_ . put ( this . lockFilePath ( lock ) , JSON . stringify ( lock ) ) ;
}
// This is for testing only
2020-11-12 21:13:28 +02:00
public async saveLock_ ( lock : Lock ) {
2020-08-02 13:28:50 +02:00
return this . saveLock ( lock ) ;
}
2020-11-12 21:13:28 +02:00
private async acquireSyncLock ( clientType : string , clientId : string ) : Promise < Lock > {
2020-08-02 13:28:50 +02:00
try {
let isFirstPass = true ;
while ( true ) {
const [ exclusiveLock , syncLock ] = await Promise . all ( [
this . activeLock ( LockType . Exclusive ) ,
this . activeLock ( LockType . Sync , clientType , clientId ) ,
] ) ;
if ( exclusiveLock ) {
throw new JoplinError ( ` Cannot acquire sync lock because the following client has an exclusive lock on the sync target: ${ this . lockToClientString ( exclusiveLock ) } ` , 'hasExclusiveLock' ) ;
}
if ( syncLock ) {
// Normally the second pass should happen immediately afterwards, but if for some reason
// (slow network, etc.) it took more than 10 seconds then refresh the lock.
if ( isFirstPass || Date . now ( ) - syncLock . updatedTime > 1000 * 10 ) {
await this . saveLock ( syncLock ) ;
}
return syncLock ;
}
// Something wrong happened, which means we saved a lock but we didn't read
// it back. Could be application error or server issue.
if ( ! isFirstPass ) throw new Error ( 'Cannot acquire sync lock: either the lock could be written but not read back. Or it was expired before it was read again.' ) ;
await this . saveLock ( {
type : LockType . Sync ,
clientType : clientType ,
clientId : clientId ,
} ) ;
isFirstPass = false ;
}
} catch ( error ) {
await this . releaseLock ( LockType . Sync , clientType , clientId ) ;
throw error ;
}
}
2020-11-12 21:13:28 +02:00
private lockToClientString ( lock : Lock ) : string {
2020-08-02 13:28:50 +02:00
return ` ( ${ lock . clientType } # ${ lock . clientId } ) ` ;
}
2020-11-12 21:13:28 +02:00
private async acquireExclusiveLock ( clientType : string , clientId : string , timeoutMs : number = 0 ) : Promise < Lock > {
2020-08-02 13:28:50 +02:00
// The logic to acquire an exclusive lock, while avoiding race conditions is as follow:
//
// - Check if there is a lock file present
//
// - If there is a lock file, see if I'm the one owning it by checking that its content has my identifier.
// - If that's the case, just write to the data file then delete the lock file.
// - If that's not the case, just wait a second or a small random length of time and try the whole cycle again-.
//
// -If there is no lock file, create one with my identifier and try the whole cycle again to avoid race condition (re-check that the lock file is really mine)-.
const startTime = Date . now ( ) ;
async function waitForTimeout() {
if ( ! timeoutMs ) return false ;
const elapsed = Date . now ( ) - startTime ;
if ( timeoutMs && elapsed < timeoutMs ) {
await time . sleep ( 2 ) ;
return true ;
}
return false ;
}
try {
while ( true ) {
const [ activeSyncLock , activeExclusiveLock ] = await Promise . all ( [
this . activeLock ( LockType . Sync ) ,
this . activeLock ( LockType . Exclusive ) ,
] ) ;
if ( activeSyncLock ) {
if ( await waitForTimeout ( ) ) continue ;
throw new JoplinError ( ` Cannot acquire exclusive lock because the following clients have a sync lock on the target: ${ this . lockToClientString ( activeSyncLock ) } ` , 'hasSyncLock' ) ;
}
if ( activeExclusiveLock ) {
if ( activeExclusiveLock . clientId === clientId ) {
// Save it again to refresh the timestamp
await this . saveLock ( activeExclusiveLock ) ;
return activeExclusiveLock ;
} else {
// If there's already an exclusive lock, wait for it to be released
if ( await waitForTimeout ( ) ) continue ;
throw new JoplinError ( ` Cannot acquire exclusive lock because the following client has an exclusive lock on the sync target: ${ this . lockToClientString ( activeExclusiveLock ) } ` , 'hasExclusiveLock' ) ;
}
} else {
// If there's not already an exclusive lock, acquire one
// then loop again to check that we really got the lock
// (to prevent race conditions)
await this . saveLock ( {
type : LockType . Exclusive ,
clientType : clientType ,
clientId : clientId ,
} ) ;
await time . msleep ( 100 ) ;
}
}
} catch ( error ) {
await this . releaseLock ( LockType . Exclusive , clientType , clientId ) ;
throw error ;
}
}
2020-11-12 21:13:28 +02:00
private autoLockRefreshHandle ( lock : Lock ) {
2020-08-02 13:28:50 +02:00
return [ lock . type , lock . clientType , lock . clientId ] . join ( '_' ) ;
}
2020-11-12 21:13:28 +02:00
startAutoLockRefresh ( lock : Lock , errorHandler : Function ) : string {
2020-08-02 13:28:50 +02:00
const handle = this . autoLockRefreshHandle ( lock ) ;
if ( this . refreshTimers_ [ handle ] ) {
throw new Error ( ` There is already a timer refreshing this lock: ${ handle } ` ) ;
}
this . refreshTimers_ [ handle ] = {
id : null ,
inProgress : false ,
} ;
2020-10-09 19:35:46 +02:00
this . refreshTimers_ [ handle ] . id = shim . setInterval ( async ( ) = > {
2020-08-02 13:28:50 +02:00
if ( this . refreshTimers_ [ handle ] . inProgress ) return ;
const defer = ( ) = > {
if ( ! this . refreshTimers_ [ handle ] ) return ;
this . refreshTimers_ [ handle ] . inProgress = false ;
} ;
this . refreshTimers_ [ handle ] . inProgress = true ;
let error = null ;
const hasActiveLock = await this . hasActiveLock ( lock . type , lock . clientType , lock . clientId ) ;
if ( ! this . refreshTimers_ [ handle ] ) return defer ( ) ; // Timeout has been cleared
if ( ! hasActiveLock ) {
2020-09-11 12:26:07 +02:00
// If the previous lock has expired, we shouldn't try to acquire a new one. This is because other clients might have performed
// in the meantime operations that invalidates the current operation. For example, another client might have upgraded the
// sync target in the meantime, so any active operation should be cancelled here. Or if the current client was upgraded
// the sync target, another client might have synced since then, making any cached data invalid.
// In some cases it should be safe to re-acquire a lock but adding support for this would make the algorithm more complex
// without much benefits.
2020-08-02 13:28:50 +02:00
error = new JoplinError ( 'Lock has expired' , 'lockExpired' ) ;
} else {
try {
await this . acquireLock ( lock . type , lock . clientType , lock . clientId ) ;
if ( ! this . refreshTimers_ [ handle ] ) return defer ( ) ; // Timeout has been cleared
} catch ( e ) {
error = e ;
}
}
if ( error ) {
if ( this . refreshTimers_ [ handle ] ) {
2020-10-09 19:35:46 +02:00
shim . clearInterval ( this . refreshTimers_ [ handle ] . id ) ;
2020-08-02 13:28:50 +02:00
delete this . refreshTimers_ [ handle ] ;
}
errorHandler ( error ) ;
}
defer ( ) ;
} , this . autoRefreshInterval_ ) ;
return handle ;
}
2020-11-12 21:13:28 +02:00
stopAutoLockRefresh ( lock : Lock ) {
2020-08-02 13:28:50 +02:00
const handle = this . autoLockRefreshHandle ( lock ) ;
if ( ! this . refreshTimers_ [ handle ] ) {
// Should not throw an error because lock may have been cleared in startAutoLockRefresh
// if there was an error.
// throw new Error(`There is no such lock being auto-refreshed: ${this.lockToString(lock)}`);
return ;
}
2020-10-09 19:35:46 +02:00
shim . clearInterval ( this . refreshTimers_ [ handle ] . id ) ;
2020-08-02 13:28:50 +02:00
delete this . refreshTimers_ [ handle ] ;
}
2020-11-12 21:13:28 +02:00
async acquireLock ( lockType : LockType , clientType : string , clientId : string , timeoutMs : number = 0 ) : Promise < Lock > {
2020-08-02 13:28:50 +02:00
if ( lockType === LockType . Sync ) {
return this . acquireSyncLock ( clientType , clientId ) ;
} else if ( lockType === LockType . Exclusive ) {
return this . acquireExclusiveLock ( clientType , clientId , timeoutMs ) ;
} else {
throw new Error ( ` Invalid lock type: ${ lockType } ` ) ;
}
}
2020-11-12 21:13:28 +02:00
async releaseLock ( lockType : LockType , clientType : string , clientId : string ) {
2020-08-02 13:28:50 +02:00
await this . api_ . delete ( this . lockFilePath ( {
type : lockType ,
clientType : clientType ,
clientId : clientId ,
} ) ) ;
}
}