mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
All: Improved first sync speed when synchronising with Joplin Server
This commit is contained in:
parent
0222c0f0a6
commit
4dc1210eb5
@ -50,7 +50,7 @@ done
|
|||||||
|
|
||||||
cd "$ROOT_DIR/packages/app-cli"
|
cd "$ROOT_DIR/packages/app-cli"
|
||||||
npm start -- --profile "$PROFILE_DIR" batch "$CMD_FILE"
|
npm start -- --profile "$PROFILE_DIR" batch "$CMD_FILE"
|
||||||
npm start -- --profile "$PROFILE_DIR" import ~/Desktop/Joplin_17_06_2021.jex
|
# npm start -- --profile "$PROFILE_DIR" import ~/Desktop/Joplin_17_06_2021.jex
|
||||||
# npm start -- --profile "$PROFILE_DIR" import ~/Desktop/Tout_18_06_2021.jex
|
npm start -- --profile "$PROFILE_DIR" import ~/Desktop/Tout_18_06_2021.jex
|
||||||
npm start -- --profile "$PROFILE_DIR" sync
|
npm start -- --profile "$PROFILE_DIR" sync
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ import JoplinError from './JoplinError';
|
|||||||
import ShareService from './services/share/ShareService';
|
import ShareService from './services/share/ShareService';
|
||||||
import TaskQueue from './TaskQueue';
|
import TaskQueue from './TaskQueue';
|
||||||
import ItemUploader from './services/synchronizer/ItemUploader';
|
import ItemUploader from './services/synchronizer/ItemUploader';
|
||||||
|
import { FileApi } from './file-api';
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
const { Dirnames } = require('./services/synchronizer/utils/types');
|
const { Dirnames } = require('./services/synchronizer/utils/types');
|
||||||
|
|
||||||
@ -27,6 +28,18 @@ interface RemoteItem {
|
|||||||
id: string;
|
id: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
type_?: number;
|
type_?: number;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
|
||||||
|
// This the time when the file was created on the server. It is used for
|
||||||
|
// example for the locking mechanim or any file that's not an actual Joplin
|
||||||
|
// item.
|
||||||
|
updated_time?: number;
|
||||||
|
|
||||||
|
// This is the time that corresponds to the actual Joplin item updated_time
|
||||||
|
// value. A note is always uploaded with a delay so the server updated_time
|
||||||
|
// value will always be ahead. However for synchronising we need to know the
|
||||||
|
// exact Joplin item updated_time value.
|
||||||
|
jop_updated_time?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCannotSyncError(error: any): boolean {
|
function isCannotSyncError(error: any): boolean {
|
||||||
@ -50,7 +63,7 @@ export default class Synchronizer {
|
|||||||
public static verboseMode: boolean = true;
|
public static verboseMode: boolean = true;
|
||||||
|
|
||||||
private db_: any;
|
private db_: any;
|
||||||
private api_: any;
|
private api_: FileApi;
|
||||||
private appType_: string;
|
private appType_: string;
|
||||||
private logger_: Logger = new Logger();
|
private logger_: Logger = new Logger();
|
||||||
private state_: string = 'idle';
|
private state_: string = 'idle';
|
||||||
@ -74,7 +87,7 @@ export default class Synchronizer {
|
|||||||
|
|
||||||
public dispatch: Function;
|
public dispatch: Function;
|
||||||
|
|
||||||
public constructor(db: any, api: any, appType: string) {
|
public constructor(db: any, api: FileApi, appType: string) {
|
||||||
this.db_ = db;
|
this.db_ = db;
|
||||||
this.api_ = api;
|
this.api_ = api;
|
||||||
this.appType_ = appType;
|
this.appType_ = appType;
|
||||||
@ -307,7 +320,7 @@ export default class Synchronizer {
|
|||||||
if (this.syncTargetIsLocked_) throw new JoplinError('Sync target is locked - aborting API call', 'lockError');
|
if (this.syncTargetIsLocked_) throw new JoplinError('Sync target is locked - aborting API call', 'lockError');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = await this.api()[fnName](...args);
|
const output = await (this.api() as any)[fnName](...args);
|
||||||
return output;
|
return output;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const lockStatus = await this.lockErrorStatus_();
|
const lockStatus = await this.lockErrorStatus_();
|
||||||
@ -769,16 +782,27 @@ export default class Synchronizer {
|
|||||||
logger: this.logger(),
|
logger: this.logger(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const remotes = listResult.items;
|
const remotes: RemoteItem[] = listResult.items;
|
||||||
|
|
||||||
this.logSyncOperation('fetchingTotal', null, null, 'Fetching delta items from sync target', remotes.length);
|
this.logSyncOperation('fetchingTotal', null, null, 'Fetching delta items from sync target', remotes.length);
|
||||||
|
|
||||||
|
const remoteIds = remotes.map(r => BaseItem.pathToId(r.path));
|
||||||
|
const locals = await BaseItem.loadItemsByIds(remoteIds);
|
||||||
|
|
||||||
for (const remote of remotes) {
|
for (const remote of remotes) {
|
||||||
if (this.cancelling()) break;
|
if (this.cancelling()) break;
|
||||||
|
|
||||||
this.downloadQueue_.push(remote.path, async () => {
|
let needsToDownload = true;
|
||||||
return this.apiCall('get', remote.path);
|
if (this.api().supportsAccurateTimestamp) {
|
||||||
});
|
const local = locals.find(l => l.id === BaseItem.pathToId(remote.path));
|
||||||
|
if (local && local.updated_time === remote.jop_updated_time) needsToDownload = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsToDownload) {
|
||||||
|
this.downloadQueue_.push(remote.path, async () => {
|
||||||
|
return this.apiCall('get', remote.path);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < remotes.length; i++) {
|
for (let i = 0; i < remotes.length; i++) {
|
||||||
@ -800,9 +824,10 @@ export default class Synchronizer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const path = remote.path;
|
const path = remote.path;
|
||||||
|
const remoteId = BaseItem.pathToId(path);
|
||||||
let action = null;
|
let action = null;
|
||||||
let reason = '';
|
let reason = '';
|
||||||
let local = await BaseItem.loadItemByPath(path);
|
let local = locals.find(l => l.id === remoteId);
|
||||||
let ItemClass = null;
|
let ItemClass = null;
|
||||||
let content = null;
|
let content = null;
|
||||||
|
|
||||||
@ -821,10 +846,14 @@ export default class Synchronizer {
|
|||||||
action = 'deleteLocal';
|
action = 'deleteLocal';
|
||||||
reason = 'remote has been deleted';
|
reason = 'remote has been deleted';
|
||||||
} else {
|
} else {
|
||||||
content = await loadContent();
|
if (this.api().supportsAccurateTimestamp && remote.jop_updated_time === local.updated_time) {
|
||||||
if (content && content.updated_time > local.updated_time) {
|
// Nothing to do, and no need to fetch the content
|
||||||
action = 'updateLocal';
|
} else {
|
||||||
reason = 'remote is more recent than local';
|
content = await loadContent();
|
||||||
|
if (content && content.updated_time > local.updated_time) {
|
||||||
|
action = 'updateLocal';
|
||||||
|
reason = 'remote is more recent than local';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,10 @@ export default class FileApiDriverJoplinServer {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get supportsAccurateTimestamp() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public requestRepeatCount() {
|
public requestRepeatCount() {
|
||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
@ -44,7 +48,8 @@ export default class FileApiDriverJoplinServer {
|
|||||||
const output = {
|
const output = {
|
||||||
path: rootPath ? path.substr(rootPath.length + 1) : path,
|
path: rootPath ? path.substr(rootPath.length + 1) : path,
|
||||||
updated_time: md.updated_time,
|
updated_time: md.updated_time,
|
||||||
isDir: false, // !!md.is_directory,
|
jop_updated_time: md.jop_updated_time,
|
||||||
|
isDir: false,
|
||||||
isDeleted: isDeleted,
|
isDeleted: isDeleted,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -24,6 +24,10 @@ export default class FileApiDriverMemory {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get supportsAccurateTimestamp() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
decodeContent_(content: any) {
|
decodeContent_(content: any) {
|
||||||
return Buffer.from(content, 'base64').toString('utf-8');
|
return Buffer.from(content, 'base64').toString('utf-8');
|
||||||
}
|
}
|
||||||
|
@ -86,10 +86,26 @@ class FileApi {
|
|||||||
if (this.driver_.initialize) return this.driver_.initialize(this.fullPath(''));
|
if (this.driver_.initialize) return this.driver_.initialize(this.fullPath(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This can be true if the driver implements uploading items in batch. Will
|
||||||
|
// probably only be supported by Joplin Server.
|
||||||
public get supportsMultiPut(): boolean {
|
public get supportsMultiPut(): boolean {
|
||||||
return !!this.driver().supportsMultiPut;
|
return !!this.driver().supportsMultiPut;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This can be true when the sync target timestamps (updated_time) provided
|
||||||
|
// in the delta call are guaranteed to be accurate. That requires
|
||||||
|
// explicitely setting the timestamp, which is not done anymore on any sync
|
||||||
|
// target as it wasn't accurate (for example, the file system can't be
|
||||||
|
// relied on, and even OneDrive for some reason doesn't guarantee that the
|
||||||
|
// timestamp you set is what you get back).
|
||||||
|
//
|
||||||
|
// The only reliable one at the moment is Joplin Server since it reads the
|
||||||
|
// updated_time property directly from the item (it unserializes it
|
||||||
|
// server-side).
|
||||||
|
public get supportsAccurateTimestamp(): boolean {
|
||||||
|
return !!this.driver().supportsAccurateTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
async fetchRemoteDateOffset_() {
|
async fetchRemoteDateOffset_() {
|
||||||
const tempFile = `${this.tempDirName()}/timeCheck${Math.round(Math.random() * 1000000)}.txt`;
|
const tempFile = `${this.tempDirName()}/timeCheck${Math.round(Math.random() * 1000000)}.txt`;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
Binary file not shown.
@ -356,6 +356,7 @@ export interface Item extends WithDates, WithUuid {
|
|||||||
jop_share_id?: Uuid;
|
jop_share_id?: Uuid;
|
||||||
jop_type?: number;
|
jop_type?: number;
|
||||||
jop_encryption_applied?: number;
|
jop_encryption_applied?: number;
|
||||||
|
jop_updated_time?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserItem extends WithDates {
|
export interface UserItem extends WithDates {
|
||||||
@ -503,6 +504,7 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
jop_share_id: { type: 'string' },
|
jop_share_id: { type: 'string' },
|
||||||
jop_type: { type: 'number' },
|
jop_type: { type: 'number' },
|
||||||
jop_encryption_applied: { type: 'number' },
|
jop_encryption_applied: { type: 'number' },
|
||||||
|
jop_updated_time: { type: 'number' },
|
||||||
},
|
},
|
||||||
user_items: {
|
user_items: {
|
||||||
id: { type: 'number' },
|
id: { type: 'number' },
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
|
export async function up(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.alterTable('items', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.integer('jop_updated_time').defaultTo(0).notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const items = await db('items')
|
||||||
|
.select('id', 'content')
|
||||||
|
.where('jop_type', '>', 0)
|
||||||
|
.andWhere('jop_updated_time', '=', 0)
|
||||||
|
.limit(1000);
|
||||||
|
|
||||||
|
if (!items.length) break;
|
||||||
|
|
||||||
|
await db.transaction(async trx => {
|
||||||
|
for (const item of items) {
|
||||||
|
const unserialized = JSON.parse(item.content);
|
||||||
|
await trx('items').update({ jop_updated_time: unserialized.updated_time }).where('id', '=', item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(_db: DbConnection): Promise<any> {
|
||||||
|
|
||||||
|
}
|
@ -5,14 +5,12 @@ import { ErrorResyncRequired } from '../utils/errors';
|
|||||||
import BaseModel, { SaveOptions } from './BaseModel';
|
import BaseModel, { SaveOptions } from './BaseModel';
|
||||||
import { PaginatedResults, Pagination, PaginationOrderDir } from './utils/pagination';
|
import { PaginatedResults, Pagination, PaginationOrderDir } from './utils/pagination';
|
||||||
|
|
||||||
export interface ChangeWithItem {
|
export interface DeltaChange extends Change {
|
||||||
item: Item;
|
jop_updated_time?: number;
|
||||||
updated_time: number;
|
|
||||||
type: ChangeType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedChanges extends PaginatedResults {
|
export interface PaginatedChanges extends PaginatedResults {
|
||||||
items: Change[];
|
items: DeltaChange[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangePagination {
|
export interface ChangePagination {
|
||||||
@ -158,9 +156,20 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
.orderBy('counter', 'asc')
|
.orderBy('counter', 'asc')
|
||||||
.limit(pagination.limit) as any[];
|
.limit(pagination.limit) as any[];
|
||||||
|
|
||||||
const changes = await query;
|
const changes: Change[] = await query;
|
||||||
|
|
||||||
const finalChanges = await this.removeDeletedItems(this.compressChanges(changes));
|
const items: Item[] = await this.db('items').select('id', 'jop_updated_time').whereIn('items.id', changes.map(c => c.item_id));
|
||||||
|
|
||||||
|
let finalChanges: DeltaChange[] = this.compressChanges(changes);
|
||||||
|
finalChanges = await this.removeDeletedItems(finalChanges, items);
|
||||||
|
finalChanges = finalChanges.map(c => {
|
||||||
|
const item = items.find(item => item.id === c.item_id);
|
||||||
|
if (!item) return c;
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
jop_updated_time: item.jop_updated_time,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: finalChanges,
|
items: finalChanges,
|
||||||
@ -171,14 +180,14 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeDeletedItems(changes: Change[]): Promise<Change[]> {
|
private async removeDeletedItems(changes: Change[], items: Item[] = null): Promise<Change[]> {
|
||||||
const itemIds = changes.map(c => c.item_id);
|
const itemIds = changes.map(c => c.item_id);
|
||||||
|
|
||||||
// We skip permission check here because, when an item is shared, we need
|
// We skip permission check here because, when an item is shared, we need
|
||||||
// to fetch files that don't belong to the current user. This check
|
// to fetch files that don't belong to the current user. This check
|
||||||
// would not be needed anyway because the change items are generated in
|
// would not be needed anyway because the change items are generated in
|
||||||
// a context where permissions have already been checked.
|
// a context where permissions have already been checked.
|
||||||
const items: Item[] = await this.db('items').select('id').whereIn('items.id', itemIds);
|
items = items === null ? await this.db('items').select('id').whereIn('items.id', itemIds) : items;
|
||||||
|
|
||||||
const output: Change[] = [];
|
const output: Change[] = [];
|
||||||
|
|
||||||
|
@ -285,6 +285,7 @@ export default class ItemModel extends BaseModel<Item> {
|
|||||||
item.share_id = itemRow.jop_share_id;
|
item.share_id = itemRow.jop_share_id;
|
||||||
item.type_ = itemRow.jop_type;
|
item.type_ = itemRow.jop_type;
|
||||||
item.encryption_applied = itemRow.jop_encryption_applied;
|
item.encryption_applied = itemRow.jop_encryption_applied;
|
||||||
|
item.updated_time = itemRow.jop_updated_time;
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@ -336,6 +337,7 @@ export default class ItemModel extends BaseModel<Item> {
|
|||||||
item.jop_type = joplinItem.type_;
|
item.jop_type = joplinItem.type_;
|
||||||
item.jop_encryption_applied = joplinItem.encryption_applied || 0;
|
item.jop_encryption_applied = joplinItem.encryption_applied || 0;
|
||||||
item.jop_share_id = joplinItem.share_id || '';
|
item.jop_share_id = joplinItem.share_id || '';
|
||||||
|
item.jop_updated_time = joplinItem.updated_time;
|
||||||
|
|
||||||
const joplinItemToSave = { ...joplinItem };
|
const joplinItemToSave = { ...joplinItem };
|
||||||
|
|
||||||
@ -344,6 +346,7 @@ export default class ItemModel extends BaseModel<Item> {
|
|||||||
delete joplinItemToSave.share_id;
|
delete joplinItemToSave.share_id;
|
||||||
delete joplinItemToSave.type_;
|
delete joplinItemToSave.type_;
|
||||||
delete joplinItemToSave.encryption_applied;
|
delete joplinItemToSave.encryption_applied;
|
||||||
|
delete joplinItemToSave.updated_time;
|
||||||
|
|
||||||
item.content = Buffer.from(JSON.stringify(joplinItemToSave));
|
item.content = Buffer.from(JSON.stringify(joplinItemToSave));
|
||||||
} else {
|
} else {
|
||||||
|
Loading…
Reference in New Issue
Block a user