From 52b66dabb4847fec91d6f241e72141b45a4f3fb9 Mon Sep 17 00:00:00 2001 From: Chalith Desaman Date: Mon, 11 Jul 2022 14:17:47 +0530 Subject: [PATCH] Introduce prune scheduler to prune orphan instances and leases (#156) --- mb-xrpl/app.js | 2 +- mb-xrpl/lib/appenv.js | 4 + mb-xrpl/lib/message-board.js | 142 +++++++++++++++++++++++++++++++++- mb-xrpl/lib/sqlite-handler.js | 4 +- mb-xrpl/package-lock.json | 14 ++-- mb-xrpl/package.json | 2 +- 6 files changed, 154 insertions(+), 14 deletions(-) diff --git a/mb-xrpl/app.js b/mb-xrpl/app.js index babd4ce..b735a7e 100644 --- a/mb-xrpl/app.js +++ b/mb-xrpl/app.js @@ -63,7 +63,7 @@ async function main() { console.log('Data dir: ' + appenv.DATA_DIR); console.log('Using Sashimono cli: ' + appenv.SASHI_CLI_PATH); - const mb = new MessageBoard(appenv.CONFIG_PATH, appenv.SECRET_CONFIG_PATH, appenv.DB_PATH, appenv.SASHI_CLI_PATH); + const mb = new MessageBoard(appenv.CONFIG_PATH, appenv.SECRET_CONFIG_PATH, appenv.DB_PATH, appenv.SASHI_CLI_PATH, appenv.SASHI_DB_PATH); await mb.init(); } diff --git a/mb-xrpl/lib/appenv.js b/mb-xrpl/lib/appenv.js index b0bf86e..0f95cdc 100644 --- a/mb-xrpl/lib/appenv.js +++ b/mb-xrpl/lib/appenv.js @@ -1,4 +1,5 @@ const process = require('process'); +const path = require('path'); let appenv = { IS_DEV_MODE: process.env.MB_DEV === "1", @@ -16,9 +17,12 @@ appenv = { DB_PATH: appenv.DATA_DIR + '/mb-xrpl.sqlite', DB_TABLE_NAME: 'leases', DB_UTIL_TABLE_NAME: 'util_data', + SASHI_DB_PATH: (appenv.IS_DEV_MODE ? "../build/" : path.join(appenv.DATA_DIR, '../')) + "sa.sqlite", + SASHI_TABLE_NAME: 'instances', LAST_WATCHED_LEDGER: 'last_watched_ledger', ACQUIRE_LEASE_TIMEOUT_THRESHOLD: 0.8, ACQUIRE_LEASE_WAIT_TIMEOUT_THRESHOLD: 0.4, + ORPHAN_PRUNE_SCHEDULER_INTERVAL_HOURS: 4, SASHI_CLI_PATH: appenv.IS_DEV_MODE ? "../build/sashi" : "/usr/bin/sashi", MB_VERSION: '0.5.4', TOS_HASH: '757A0237B44D8B2BBB04AE2BAD5813858E0AECD2F0B217075E27E0630BA74314' // This is the sha256 hash of TOS text. diff --git a/mb-xrpl/lib/message-board.js b/mb-xrpl/lib/message-board.js index 17530c9..2679bc8 100644 --- a/mb-xrpl/lib/message-board.js +++ b/mb-xrpl/lib/message-board.js @@ -15,7 +15,7 @@ const LeaseStatus = { } class MessageBoard { - constructor(configPath, secretConfigPath, dbPath, sashiCliPath) { + constructor(configPath, secretConfigPath, dbPath, sashiCliPath, sashiDbPath) { this.configPath = configPath; this.secretConfigPath = secretConfigPath; this.leaseTable = appenv.DB_TABLE_NAME; @@ -28,6 +28,8 @@ class MessageBoard { this.sashiCli = new SashiCLI(sashiCliPath); this.db = new SqliteDatabase(dbPath); + this.sashiDb = new SqliteDatabase(sashiDbPath); + this.sashiTable = appenv.SASHI_TABLE_NAME } async init() { @@ -81,7 +83,7 @@ class MessageBoard { const currentMoment = await this.hostClient.getMoment(e.ledger_index); // Sending heartbeat every CONF_HOST_HEARTBEAT_FREQ moments. - if ( ! ongoingHeartbeat && + if (!ongoingHeartbeat && (this.lastHeartbeatMoment === 0 || (currentMoment % this.hostClient.config.hostHeartbeatFreq === 0 && currentMoment !== this.lastHeartbeatMoment))) { ongoingHeartbeat = true; console.log(`Reporting heartbeat at Moment ${this.lastHeartbeatMoment}...`); @@ -117,7 +119,7 @@ class MessageBoard { console.log(`Cannot find a NFT for ${x.containerName}`); else { const uriInfo = evernode.UtilHelpers.decodeLeaseNftUri(nft.URI); - await this.destroyInstance(x.containerName, x.tenant, uriInfo.leaseIndex, true); + await this.destroyInstance(x.containerName, x.tenant, uriInfo.leaseIndex); } this.activeInstanceCount--; @@ -142,6 +144,9 @@ class MessageBoard { this.hostClient.on(evernode.HostEvents.AcquireLease, r => this.handleAcquireLease(r)); this.hostClient.on(evernode.HostEvents.ExtendLease, r => this.handleExtendLease(r)); + + // Start a job to prune the orphan instances. + this.#startPruneScheduler(); } // Connect the host and trying to reconnect in the event of account not found error. @@ -172,6 +177,137 @@ class MessageBoard { } } + #startPruneScheduler() { + const timeout = appenv.ORPHAN_PRUNE_SCHEDULER_INTERVAL_HOURS * 3600000; // Hours to millisecs. + + const scheduler = async () => { + console.log(`Starting the scheduled prune job...`); + await this.#pruneOrphanLeases().catch(console.error); + console.log(`Stopped the scheduled prune job.`); + setTimeout(async () => { + await scheduler(); + }, timeout); + }; + + setTimeout(async () => { + await scheduler(); + }, timeout); + } + + async #pruneOrphanLeases() { + // Note: If this is soft deletion we need to handle the destroyed status and replace deleteLeaseRecord with changing the status. + + // Get the records which are created before an acquire timeout x 2. + // Take the xrpl ledger time as 4 seconds. + const timeoutSecs = (this.hostClient.config.leaseAcquireWindow * 4 * appenv.ACQUIRE_LEASE_TIMEOUT_THRESHOLD) * 2; + const timeMargin = new Date(Date.now() - (1000 * timeoutSecs)); + + this.sashiDb.open(); + const instances = (await this.sashiDb.getValues(this.sashiTable)); + this.sashiDb.close(); + this.db.open(); + const leases = (await this.db.getValues(this.leaseTable)); + this.db.close(); + + let activeInstanceCount = leases.filter(r => (r.status === LeaseStatus.ACQUIRED || r.status === LeaseStatus.EXTENDED)).length; + + // Remove the instances which are orphan. + // Only consider the older ones. + for (const instance of instances.filter(i => i.time < timeMargin)) { + try { + const leaseIndex = leases.findIndex(l => l.container_name === instance.name); + const lease = leaseIndex >= 0 ? leases[leaseIndex] : null; + // If there's a lease record this is created from message board. + if (lease) { + leases.splice(leaseIndex, 1); + const nft = (await (new evernode.XrplAccount(lease.tenant_xrp_address)).getNfts())?.find(n => n.NFTokenID == instance.name); + + // If lease is in ACQUIRING status acquire response is not received by the tenant and lease is not in expiry list. + // If the NFT is not owned by the tenant we destroy the instance since this is not a valid lease. + // In these cases, destroy the instance. + if (lease.status === LeaseStatus.ACQUIRING || !nft) { + console.log(`Pruning orphan instance ${instance.name}...`); + await this.sashiCli.destroyInstance(instance.name); + + // After destroying, If the NFT is owned by the tenant, burn the NFT and recreate and refund the tenant. + if (nft) { + const uriInfo = evernode.UtilHelpers.decodeLeaseNftUri(nft.URI); + await this.recreateLeaseOffer(instance.name, lease.tenant_xrp_address, uriInfo.leaseIndex); + + console.log(`Refunding tenant ${lease.tenant_xrp_address}...`); + await this.hostClient.refundTenant(lease.tx_hash, lease.tenant_xrp_address, uriInfo.leaseAmount.toString()); + } + + // Remove the lease record. + if (lease) { + this.db.open(); + await this.deleteLeaseRecord(lease.tx_hash); + this.db.close(); + + if (lease.status === LeaseStatus.ACQUIRED || lease.status === LeaseStatus.EXTENDED) + activeInstanceCount--; + } + } + } + else { + // If there's no lease but the name matches with NFT pattern, + // This is created from the message board but lease record is missing. + const namePrefix = this.hostClient.getLeaseNFTokenIdPrefix(); + if (instance.name.startsWith(namePrefix)) { + console.log(`Pruning orphan instance ${instance.name}...`); + await this.sashiCli.destroyInstance(instance.name); + } + } + } + catch (e) { + console.error(e); + } + } + + // Remove the leases which are orphan (Does not have an instance). + // Only consider the older ones. + for (const lease of leases.filter(l => l.timestamp < timeMargin && (l.status === LeaseStatus.ACQUIRING || l.status === LeaseStatus.ACQUIRED || l.status === LeaseStatus.EXTENDED))) { + try { + // If lease does not have an instance. + this.sashiDb.open(); + const instances = (await this.sashiDb.getValues(this.sashiTable, { name: lease.container_name })); + this.sashiDb.close(); + + if (!instances || instances.length === 0) { + console.log(`Pruning orphan lease ${lease.container_name}...`); + + this.db.open(); + await this.deleteLeaseRecord(lease.tx_hash); + this.db.close(); + + if (lease.status === LeaseStatus.ACQUIRED || lease.status === LeaseStatus.EXTENDED) + activeInstanceCount--; + + const nft = (await (new evernode.XrplAccount(lease.tenant_xrp_address)).getNfts())?.find(n => n.NFTokenID == lease.container_name); + if (nft) { + const uriInfo = evernode.UtilHelpers.decodeLeaseNftUri(nft.URI); + await this.recreateLeaseOffer(lease.container_name, lease.tenant_xrp_address, uriInfo.leaseIndex); + + // If lease is in ACQUIRING status acquire response is not received by the tenant and lease is not in expiry list. + if (lease.status === LeaseStatus.ACQUIRING) { + console.log(`Refunding tenant ${lease.tenant_xrp_address}...`); + await this.hostClient.refundTenant(lease.tx_hash, lease.tenant_xrp_address, uriInfo.leaseAmount.toString()); + } + } + } + } + catch (e) { + console.error(e); + } + } + + // If active instance count is updated, Send the update registration transaction. + if (this.activeInstanceCount !== activeInstanceCount) { + this.activeInstanceCount = activeInstanceCount; + await this.hostClient.updateRegInfo(this.activeInstanceCount); + } + } + async recreateLeaseOffer(nfTokenId, tenantAddress, leaseIndex) { // Burn the NFTs and recreate the offer and send back the lease amount back to the tenant. await this.hostClient.expireLease(nfTokenId, tenantAddress).catch(console.error); diff --git a/mb-xrpl/lib/sqlite-handler.js b/mb-xrpl/lib/sqlite-handler.js index d4722c0..82fb2f2 100644 --- a/mb-xrpl/lib/sqlite-handler.js +++ b/mb-xrpl/lib/sqlite-handler.js @@ -72,7 +72,7 @@ class SqliteDatabase { }); } - getValues(tableName, filter = null) { + getValues(tableName, filter = null, op = '=') { if (!this.db) throw 'Database connection is not open.'; @@ -81,7 +81,7 @@ class SqliteDatabase { if (filter) { const columnNames = Object.keys(filter); for (const columnName of columnNames) { - filterStr += `${columnName} = ? AND `; + filterStr += `${columnName} ${op} ? AND `; values.push(filter[columnName] ? filter[columnName] : 'NULL'); } } diff --git a/mb-xrpl/package-lock.json b/mb-xrpl/package-lock.json index 3316cd5..f2bdcbc 100644 --- a/mb-xrpl/package-lock.json +++ b/mb-xrpl/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "mb-xrpl", "dependencies": { - "evernode-js-client": "0.4.39", + "evernode-js-client": "0.4.45", "sqlite3": "5.0.2" }, "devDependencies": { @@ -996,9 +996,9 @@ } }, "node_modules/evernode-js-client": { - "version": "0.4.39", - "resolved": "https://registry.npmjs.org/evernode-js-client/-/evernode-js-client-0.4.39.tgz", - "integrity": "sha512-CLGTGUuBiPsocUpIt0WZKkBRtGcol80uBKbTlvfz9StdAU8H6zhfUMOCU5KmiPxjOWf4Bcx69z04FIwOmqmW1A==", + "version": "0.4.45", + "resolved": "https://registry.npmjs.org/evernode-js-client/-/evernode-js-client-0.4.45.tgz", + "integrity": "sha512-gybY1TxytT+OMQsDj1g8F+mfeeZy4uwfaAOKx4yXI4fNdiaA9Ji23+WVB9mPyIxruunbGTdvmbdyBxIaYeZ+tQ==", "dependencies": { "elliptic": "6.5.4", "ripple-address-codec": "4.2.0", @@ -3899,9 +3899,9 @@ "dev": true }, "evernode-js-client": { - "version": "0.4.39", - "resolved": "https://registry.npmjs.org/evernode-js-client/-/evernode-js-client-0.4.39.tgz", - "integrity": "sha512-CLGTGUuBiPsocUpIt0WZKkBRtGcol80uBKbTlvfz9StdAU8H6zhfUMOCU5KmiPxjOWf4Bcx69z04FIwOmqmW1A==", + "version": "0.4.45", + "resolved": "https://registry.npmjs.org/evernode-js-client/-/evernode-js-client-0.4.45.tgz", + "integrity": "sha512-gybY1TxytT+OMQsDj1g8F+mfeeZy4uwfaAOKx4yXI4fNdiaA9Ji23+WVB9mPyIxruunbGTdvmbdyBxIaYeZ+tQ==", "requires": { "elliptic": "6.5.4", "ripple-address-codec": "4.2.0", diff --git a/mb-xrpl/package.json b/mb-xrpl/package.json index 593a111..7ffe0bb 100644 --- a/mb-xrpl/package.json +++ b/mb-xrpl/package.json @@ -5,7 +5,7 @@ "build": "npm run lint && ncc build app.js --minify -o dist" }, "dependencies": { - "evernode-js-client": "0.4.39", + "evernode-js-client": "0.4.45", "sqlite3": "5.0.2" }, "devDependencies": {