From 91d6518e7b4748799445fda38f283d2070817ce1 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Mon, 26 Aug 2024 09:11:25 +0200 Subject: [PATCH] Use DuckDB to store device states (#2104) --- .github/workflows/docker-dev-build.yml | 2 +- .github/workflows/docker-release-build.yml | 2 +- .gitignore | 2 + docker/Dockerfile | 35 +- docker/Dockerfile.buildx | 30 +- .../actions/signup/signupSetPreferences.js | 6 - .../boxs/chart/ApexChartAreaOptions.js | 9 +- .../boxs/chart/ApexChartBarOptions.js | 9 +- .../boxs/chart/ApexChartLineOptions.js | 9 +- .../boxs/chart/ApexChartStepLineOptions.js | 9 +- front/src/config/i18n/de.json | 22 +- front/src/config/i18n/en.json | 22 +- front/src/config/i18n/fr.json | 22 +- front/src/routes/dashboard/DashboardPage.jsx | 6 + front/src/routes/dashboard/index.js | 32 +- .../SettingsSystemDuckDbMigration.jsx | 190 +++++ .../SettingsSystemKeepAggregatedStates.jsx | 98 --- .../settings-system/SettingsSystemPage.jsx | 4 +- server/.eslintrc.json | 2 +- server/api/controllers/device.controller.js | 36 +- server/api/routes.js | 12 + server/config/scheduler-jobs.js | 5 - server/index.js | 30 +- server/jsconfig.json | 6 +- .../lib/device/device.calculateAggregate.js | 118 --- .../device.calculcateAggregateChildProcess.js | 183 ----- server/lib/device/device.destroy.js | 9 + .../device.getDeviceFeaturesAggregates.js | 102 +-- .../device/device.getDuckDbMigrationState.js | 25 + server/lib/device/device.init.js | 12 +- .../device.migrateFromSQLiteToDuckDb.js | 85 ++ .../device.onHourlyDeviceAggregateEvent.js | 38 - .../lib/device/device.onPurgeStatesEvent.js | 1 - .../lib/device/device.purgeAggregateStates.js | 44 -- .../lib/device/device.purgeAllSqliteStates.js | 104 +++ server/lib/device/device.purgeStates.js | 13 +- .../lib/device/device.saveHistoricalState.js | 29 +- server/lib/device/device.saveState.js | 5 +- server/lib/device/index.js | 35 +- server/lib/gateway/gateway.backup.js | 35 +- server/lib/gateway/gateway.downloadBackup.js | 42 +- .../gateway/gateway.getLatestGladysVersion.js | 6 +- server/lib/gateway/gateway.restoreBackup.js | 38 +- .../lib/gateway/gateway.restoreBackupEvent.js | 4 +- server/lib/index.js | 7 +- server/lib/job/job.get.js | 5 + server/models/index.js | 59 ++ server/package-lock.json | 734 ++++++++++++++++-- server/package.json | 3 +- server/test/bootstrap.test.js | 2 +- .../device/device.controller.test.js | 63 +- server/test/helpers/db.test.js | 3 + .../device/device.calculateAggregate.test.js | 165 ---- server/test/lib/device/device.destroy.test.js | 10 +- ...device.getDeviceFeaturesAggregates.test.js | 31 +- ...e.getDeviceFeaturesAggregatesMulti.test.js | 8 +- .../device.getDuckDbMigrationState.test.js | 38 + server/test/lib/device/device.init.test.js | 6 +- .../device.migrateFromSQLiteToDuckDb.test.js | 78 ++ .../device.purgeAggregateStates.test.js | 54 -- .../device.purgeAllSqliteStates.test.js | 65 ++ .../lib/device/device.purgeStates.test.js | 19 + .../device/device.saveHistoricalState.test.js | 12 +- ...ded-gladys-db-and-duckdb-backup.tar.gz.enc | Bin 0 -> 15104 bytes ...=> encoded-old-gladys-db-backup.db.gz.enc} | Bin .../test/lib/gateway/gateway.backup.test.js | 4 +- .../gateway/gateway.downloadBackup.test.js | 21 +- .../lib/gateway/gateway.restoreBackup.test.js | 9 +- .../gateway.restoreBackupEvent.test.js | 25 +- .../gladys_backup_parquet_folder/load.sql | 1 + .../gladys_backup_parquet_folder/schema.sql | 8 + .../t_device_feature_state.parquet | Bin 0 -> 119 bytes server/test/lib/job/job.test.js | 37 +- server/utils/constants.js | 5 + server/utils/date.js | 28 + 75 files changed, 1940 insertions(+), 1088 deletions(-) create mode 100644 front/src/routes/settings/settings-system/SettingsSystemDuckDbMigration.jsx delete mode 100644 front/src/routes/settings/settings-system/SettingsSystemKeepAggregatedStates.jsx delete mode 100644 server/lib/device/device.calculateAggregate.js delete mode 100644 server/lib/device/device.calculcateAggregateChildProcess.js create mode 100644 server/lib/device/device.getDuckDbMigrationState.js create mode 100644 server/lib/device/device.migrateFromSQLiteToDuckDb.js delete mode 100644 server/lib/device/device.onHourlyDeviceAggregateEvent.js delete mode 100644 server/lib/device/device.purgeAggregateStates.js create mode 100644 server/lib/device/device.purgeAllSqliteStates.js delete mode 100644 server/test/lib/device/device.calculateAggregate.test.js create mode 100644 server/test/lib/device/device.getDuckDbMigrationState.test.js create mode 100644 server/test/lib/device/device.migrateFromSQLiteToDuckDb.test.js delete mode 100644 server/test/lib/device/device.purgeAggregateStates.test.js create mode 100644 server/test/lib/device/device.purgeAllSqliteStates.test.js create mode 100644 server/test/lib/gateway/encoded-gladys-db-and-duckdb-backup.tar.gz.enc rename server/test/lib/gateway/{encoded-gladys-db-backup.db.gz.enc => encoded-old-gladys-db-backup.db.gz.enc} (100%) create mode 100644 server/test/lib/gateway/gladys_backup_parquet_folder/load.sql create mode 100644 server/test/lib/gateway/gladys_backup_parquet_folder/schema.sql create mode 100644 server/test/lib/gateway/gladys_backup_parquet_folder/t_device_feature_state.parquet create mode 100644 server/utils/date.js diff --git a/.github/workflows/docker-dev-build.yml b/.github/workflows/docker-dev-build.yml index a084d6301d..d82b8f6185 100644 --- a/.github/workflows/docker-dev-build.yml +++ b/.github/workflows/docker-dev-build.yml @@ -10,7 +10,7 @@ on: platforms: description: 'Docker platform to build' required: true - default: 'linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8' + default: 'linux/amd64,linux/arm/v7,linux/arm64/v8' jobs: build-front: diff --git a/.github/workflows/docker-release-build.yml b/.github/workflows/docker-release-build.yml index 54689f0ee3..6f6ef89c07 100644 --- a/.github/workflows/docker-release-build.yml +++ b/.github/workflows/docker-release-build.yml @@ -168,7 +168,7 @@ jobs: with: context: . file: ./docker/Dockerfile.buildx - platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 + platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 push: true pull: true tags: ${{ steps.docker_meta.outputs.tags }} diff --git a/.gitignore b/.gitignore index ae3c2b2c8e..d71f233591 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules *.db +*.duckdb +*.duckdb.wal *.db-shm *.db-wal *.dbfile-shm diff --git a/docker/Dockerfile b/docker/Dockerfile index 03175e56f2..f43a5889fd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ ARG TARGET ARG VERSION ARG BUILD_DATE -FROM $TARGET/node:18-alpine +FROM ${TARGET}/node:18-slim LABEL \ org.label-schema.build-date=$BUILD_DATE \ @@ -11,23 +11,38 @@ LABEL \ COPY qemu-* /usr/bin/ # System dependencies -RUN apk add --no-cache tzdata nmap ffmpeg sqlite openssl gzip eudev +RUN apt-get update && apt-get install -y --no-install-recommends \ + tzdata \ + nmap \ + ffmpeg \ + sqlite3 \ + openssl \ + gzip \ + udev \ + bluez \ + && rm -rf /var/lib/apt/lists/* WORKDIR /tmp -# Install Bluez dependencies -RUN apk add --no-cache bluez - # Install Gladys RUN mkdir /src WORKDIR /src ADD . /src COPY ./static /src/server/static WORKDIR /src/server -RUN apk add --no-cache --virtual .build-deps make gcc g++ python3 py3-setuptools git libffi-dev linux-headers \ - && npm ci --unsafe-perm --production \ - && npm cache clean --force \ - && apk del .build-deps + +# Install build dependencies and application dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + python3 \ + python3-pip \ + git \ + libffi-dev \ + && npm ci --unsafe-perm --production \ + && npm cache clean --force \ + && apt-get autoremove -y build-essential python3 python3-pip git libffi-dev \ + && apt-get purge -y --auto-remove \ + && rm -rf /var/lib/apt/lists/* ENV NODE_ENV production ENV SERVER_PORT 80 @@ -35,4 +50,4 @@ ENV SERVER_PORT 80 # Export listening port EXPOSE 80 -CMD ["node", "index.js"] +CMD ["node", "index.js"] \ No newline at end of file diff --git a/docker/Dockerfile.buildx b/docker/Dockerfile.buildx index 35e4c45c3f..deb8003599 100644 --- a/docker/Dockerfile.buildx +++ b/docker/Dockerfile.buildx @@ -1,27 +1,27 @@ # STEP 1 # Prepare server package*.json files -FROM node:18-alpine as json-files +FROM node:18-slim as json-files COPY ./server /json-files/server WORKDIR /json-files/server/ RUN find . -type f \! -name "package*.json" -exec rm -r {} \; COPY ./server/cli /json-files/server/cli COPY ./server/utils /json-files/server/utils - # STEP 3 # Gladys Bundle -FROM node:18-alpine as gladys +FROM node:18-slim as gladys # System dependencies -RUN apk add --no-cache \ +RUN apt-get update && apt-get install -y --no-install-recommends \ tzdata \ nmap \ ffmpeg \ - sqlite \ + sqlite3 \ openssl \ gzip \ - eudev \ - bluez + udev \ + bluez \ + && rm -rf /var/lib/apt/lists/* COPY --from=json-files /json-files/server /src/server @@ -29,10 +29,18 @@ ENV LD_LIBRARY_PATH /lib WORKDIR /src/server -RUN apk add --no-cache --virtual .build-deps make gcc g++ python3 py3-setuptools git libffi-dev linux-headers \ - && npm ci --unsafe-perm --production \ - && npm cache clean --force \ - && apk del .build-deps +# Install build dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + python3 \ + python3-pip \ + git \ + libffi-dev \ + && npm ci --unsafe-perm --production \ + && npm cache clean --force \ + && apt-get autoremove -y build-essential python3 python3-pip git libffi-dev \ + && apt-get purge -y --auto-remove \ + && rm -rf /var/lib/apt/lists/* # Copy builded front COPY ./static /src/server/static diff --git a/front/src/actions/signup/signupSetPreferences.js b/front/src/actions/signup/signupSetPreferences.js index 02397986d4..08a4e0de56 100644 --- a/front/src/actions/signup/signupSetPreferences.js +++ b/front/src/actions/signup/signupSetPreferences.js @@ -59,12 +59,6 @@ function createActions(store) { await state.httpClient.post(`/api/v1/variable/${SYSTEM_VARIABLE_NAMES.DEVICE_STATE_HISTORY_IN_DAYS}`, { value: state.signupSystemPreferences[SYSTEM_VARIABLE_NAMES.DEVICE_STATE_HISTORY_IN_DAYS] }); - await state.httpClient.post( - `/api/v1/variable/${SYSTEM_VARIABLE_NAMES.DEVICE_AGGREGATE_STATE_HISTORY_IN_DAYS}`, - { - value: state.signupSystemPreferences[SYSTEM_VARIABLE_NAMES.DEVICE_STATE_HISTORY_IN_DAYS] - } - ); store.setState({ signupSaveSystemPreferences: RequestStatus.Success }); diff --git a/front/src/components/boxs/chart/ApexChartAreaOptions.js b/front/src/components/boxs/chart/ApexChartAreaOptions.js index a18875760a..31cb56bc68 100644 --- a/front/src/components/boxs/chart/ApexChartAreaOptions.js +++ b/front/src/components/boxs/chart/ApexChartAreaOptions.js @@ -51,7 +51,14 @@ const getApexChartAreaOptions = ({ displayAxes, height, series, colors, locales, }, yaxis: { labels: { - padding: 4 + padding: 4, + formatter: function(value) { + if (Math.abs(value) < 1) { + return value; // For very low values, like crypto prices, use the normal value + } else { + return value.toFixed(2); // 2 decimal places for other values + } + } } }, colors, diff --git a/front/src/components/boxs/chart/ApexChartBarOptions.js b/front/src/components/boxs/chart/ApexChartBarOptions.js index 43705a389e..f26de7d78d 100644 --- a/front/src/components/boxs/chart/ApexChartBarOptions.js +++ b/front/src/components/boxs/chart/ApexChartBarOptions.js @@ -59,7 +59,14 @@ const getApexChartBarOptions = ({ displayAxes, series, colors, locales, defaultL }, yaxis: { labels: { - padding: 4 + padding: 4, + formatter: function(value) { + if (Math.abs(value) < 1) { + return value; // For very low values, like crypto prices, use the normal value + } else { + return value.toFixed(2); // 2 decimal places for other values + } + } } }, colors, diff --git a/front/src/components/boxs/chart/ApexChartLineOptions.js b/front/src/components/boxs/chart/ApexChartLineOptions.js index 990a92ee80..24a9f2365d 100644 --- a/front/src/components/boxs/chart/ApexChartLineOptions.js +++ b/front/src/components/boxs/chart/ApexChartLineOptions.js @@ -50,7 +50,14 @@ const getApexChartLineOptions = ({ height, displayAxes, series, colors, locales, }, yaxis: { labels: { - padding: 4 + padding: 4, + formatter: function(value) { + if (Math.abs(value) < 1) { + return value; // For very low values, like crypto prices, use the normal value + } else { + return value.toFixed(2); // 2 decimal places for other values + } + } } }, colors, diff --git a/front/src/components/boxs/chart/ApexChartStepLineOptions.js b/front/src/components/boxs/chart/ApexChartStepLineOptions.js index b6b695e100..5cac219c71 100644 --- a/front/src/components/boxs/chart/ApexChartStepLineOptions.js +++ b/front/src/components/boxs/chart/ApexChartStepLineOptions.js @@ -49,7 +49,14 @@ const getApexChartStepLineOptions = ({ height, displayAxes, series, colors, loca }, yaxis: { labels: { - padding: 4 + padding: 4, + formatter: function(value) { + if (Math.abs(value) < 1) { + return value; // For very low values, like crypto prices, use the normal value + } else { + return value.toFixed(2); // 2 decimal places for other values + } + } } }, colors, diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 6d6b9b5510..f84890ac03 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -18,7 +18,9 @@ "fahrenheit": "F", "metersPerSec": "m/s", "milesPerHour": "m/h", - "selectPlaceholder": "Auswählen …" + "selectPlaceholder": "Auswählen …", + "yes": "Ja", + "no": "Nein" }, "color": { "aqua": "Aqua", @@ -231,6 +233,7 @@ "houseLabel": "Zuhause", "tabletModeDisabled": "Tablet-Modus deaktiviert" }, + "duckDbMigrationInProgress": "Ihre Instanz Gladys ist dabei, ihre Datenbank auf DuckDB umzustellen, ein neues, leistungsfähigeres Datenbanksystem für Zeitseriendaten. Diese Aufgabe kann einige Zeit in Anspruch nehmen, und während dieser Zeit werden nicht alle Ihre Grafiken verfügbar sein. Fortschritt der Migration = {{progress}}%", "editDashboardSaveButton": "Sichern", "emptyDashboardSentenceTop": "Dein Dashboard wurde noch nicht konfiguriert.", "emptyDashboardSentenceBottom": "Klicke auf \"Bearbeiten\", um dein Dashboard zu gestalten.", @@ -2362,7 +2365,9 @@ "gladys-gateway-backup": "Gladys-Plus-Backup", "device-state-purge-single-feature": "Zustände einzelner Gerätemerkmale aufräumen", "vacuum": "Datenbank reinigen", - "service-zigbee2mqtt-backup": "Zigbee2MQTT-Backup" + "service-zigbee2mqtt-backup": "Zigbee2MQTT-Backup", + "migrate-sqlite-to-duckdb": "DuckDB migration", + "device-state-purge-all-sqlite-states": "Bereinigung aller SQLite-Berichte" }, "jobErrors": { "purged-when-restarted": "Gladys Assistant wurde neu gestartet, während diese Aufgabe noch lief. Daher wurde sie gelöscht. Das bedeutet nicht, dass die Aufgabe fehlgeschlagen ist – es handelt sich um normales Verhalten." @@ -2415,7 +2420,18 @@ "dead": "Beendet" }, "batteryLevel": "Batteriestandswarnung", - "batteryLevelDescription": "Jeden Samstag um 9:00 Uhr wird eine Nachricht an alle Administratoren gesendet, wenn der Batteriestand eines Geräts unter den gewählten Schwellenwert fällt." + "batteryLevelDescription": "Jeden Samstag um 9:00 Uhr wird eine Nachricht an alle Administratoren gesendet, wenn der Batteriestand eines Geräts unter den gewählten Schwellenwert fällt.", + "duckDbMigrationTitle": "Migration zu DuckDB", + "duckDbMigrationDescription": "Wir migrieren zu einem neuen Datenbanksystem für Sensordaten. Gladys wird Ihre Daten automatisch migrieren, aber Ihre Daten in Ihrer SQLite-Datenbank nicht löschen, um Datenverlust zu vermeiden.", + "duckDbMigrationProgressTitle": "Migrationsfortschritt", + "duckDbMigrationMigrationDone": "Migration abgeschlossen:", + "duckDbNumberOfStatesinSQlite": "Anzahl der Zustände in SQLite", + "duckDbNumberOfStatesinDuckDb": "Anzahl der Zustände in DuckDB", + "restartMigrationTitle": "Migration neu starten", + "confirm": "Bestätigen", + "cancel": "Abbrechen", + "purgeSQliteTitle": "SQLite-Zustände löschen", + "purgeSQliteDescription": "Sobald alle Zustände aus SQLite gelöscht wurden, müssen Sie auf der rechten Registerkarte eine \"Datenbankbereinigung\" durchführen, um Speicherplatz freizugeben. Dieser Schritt kann einige Zeit in Anspruch nehmen." }, "newArea": { "createNewZoneButton": "Neue Zone erstellen", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 29d9eb7c0a..e84642cdc1 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -18,7 +18,9 @@ "fahrenheit": "F", "metersPerSec": "m/s", "milesPerHour": "m/h", - "selectPlaceholder": "Select..." + "selectPlaceholder": "Select...", + "yes": "Yes", + "no": "No" }, "color": { "aqua": "Aqua", @@ -231,6 +233,7 @@ "houseLabel": "House", "tabletModeDisabled": "Tablet Mode Disabled" }, + "duckDbMigrationInProgress": "Votre instance Gladys est entrain de migrer sa base de donnée à DuckDB, un nouveau système de base de donnée plus performant pour les données time-series. Cette tâche peut prendre un certain temps, et pendant ce temps vos graphiques ne seront pas tous disponibles. Progression de la migration = {{progress}}%", "editDashboardSaveButton": "Save", "emptyDashboardSentenceTop": "Looks like your dashboard is not configured yet.", "emptyDashboardSentenceBottom": "Click on the \"Edit\" button to design your dashboard.", @@ -2362,7 +2365,9 @@ "gladys-gateway-backup": "Gladys Plus backup", "device-state-purge-single-feature": "Single device feature states clean", "vacuum": "Database cleaning", - "service-zigbee2mqtt-backup": "Zigbee2MQTT backup" + "service-zigbee2mqtt-backup": "Zigbee2MQTT backup", + "migrate-sqlite-to-duckdb": "DuckDB migration", + "device-state-purge-all-sqlite-states": "Purge all SQLite states" }, "jobErrors": { "purged-when-restarted": "Gladys Assistant restarted while this job was still running, so it was purged. It doesn't mean the job has failed, it's a normal behavior." @@ -2415,7 +2420,18 @@ "dead": "Dead" }, "batteryLevel": "Battery level alert", - "batteryLevelDescription": "Every Saturday at 9:00 am, a message will be sent to all administrators if a device's battery level falls below the chosen threshold." + "batteryLevelDescription": "Every Saturday at 9:00 am, a message will be sent to all administrators if a device's battery level falls below the chosen threshold.", + "duckDbMigrationTitle": "Migration to DuckDB", + "duckDbMigrationDescription": "We are migrating to a new database system for sensor data. Gladys will automatically migrate your data, but will not delete your data from your SQLite database to prevent data loss.", + "duckDbMigrationProgressTitle": "Migration Progress", + "duckDbMigrationMigrationDone": "Migration completed:", + "duckDbNumberOfStatesinSQlite": "Number of states in SQLite", + "duckDbNumberOfStatesinDuckDb": "Number of states in DuckDB", + "restartMigrationTitle": "Restart Migration", + "confirm": "Confirm", + "cancel": "Cancel", + "purgeSQliteTitle": "Purge SQLite states", + "purgeSQliteDescription": "Once all states are deleted from SQLite, you will need to perform a \"database cleanup\" on the tab to the right to free up disk space. This step may take some time." }, "newArea": { "createNewZoneButton": "Create new zone", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index fffab12fb1..fc5ea8c150 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -18,7 +18,9 @@ "fahrenheit": "F", "metersPerSec": "m/s", "milesPerHour": "m/h", - "selectPlaceholder": "Sélectionnez..." + "selectPlaceholder": "Sélectionnez...", + "yes": "Oui", + "no": "Non" }, "color": { "aqua": "Aqua", @@ -229,6 +231,7 @@ "houseLabel": "Maison", "tabletModeDisabled": "Mode tablette désactivé" }, + "duckDbMigrationInProgress": "Votre instance Gladys est en train de migrer sa base de données à DuckDb, un nouveau système de base de données plus performant pour les données time-series. Cette tâche peut prendre un certain temps, et pendant ce temps vos graphiques ne seront pas tous disponibles. Progression de la migration = {{progress}}%", "enableFullScreen": "Plein écran", "disableFullScreen": "Quitter plein écran", "editDashboardTitle": "Éditer le tableau de bord", @@ -2362,7 +2365,9 @@ "device-state-purge-single-feature": "Nettoyage des états d'un appareil", "device-state-purge": "Nettoyage des vieux états d'appareils", "vacuum": "Nettoyage de la base de données", - "service-zigbee2mqtt-backup": "Sauvegarde Zigbee2MQTT" + "service-zigbee2mqtt-backup": "Sauvegarde Zigbee2MQTT", + "migrate-sqlite-to-duckdb": "Migration vers DuckDb", + "device-state-purge-all-sqlite-states": "Purge de tous les états SQLite" }, "jobErrors": { "purged-when-restarted": "Gladys Assistant a redémarré alors que cette tâche était en cours. Cela ne veut pas dire que cette tâche a échouée, c'est un comportement normal." @@ -2415,7 +2420,18 @@ "dead": "Mort" }, "batteryLevel": "Alerte sur le niveau de batterie", - "batteryLevelDescription": "Tous les samedis à 9h00, un message sera envoyé à tous les administrateurs si le niveau de batterie d'un appareil passe en dessous du seuil choisi." + "batteryLevelDescription": "Tous les samedis à 9h00, un message sera envoyé à tous les administrateurs si le niveau de batterie d'un appareil passe en dessous du seuil choisi.", + "duckDbMigrationTitle": "Migration vers DuckDB", + "duckDbMigrationDescription": "Nous migrons vers un nouveau système de base de donnée pour les données de capteurs. Gladys va effectuer une migration de vos données automatiquement, mais ne supprimera pas vos données de votre base SQLite afin d'éviter les pertes de données.", + "duckDbMigrationProgressTitle": "Avancement de la migration", + "duckDbMigrationMigrationDone": "Migration effectuée :", + "duckDbNumberOfStatesinSQlite": "Nombre d'états dans SQLite", + "duckDbNumberOfStatesinDuckDb": "Nombre d'états dans DuckDB", + "restartMigrationTitle": "Relancer la migration", + "confirm": "Confirmer", + "cancel": "Annuler", + "purgeSQliteTitle": "Purger les états SQLite", + "purgeSQliteDescription": "Une fois que tous les états seront supprimés de SQLite, vous devrez faire un \"nettoyage de la base de données\" sur l'onglet à droite afin de relâcher l'espace disque. Cette étape peut prendre un certain temps." }, "newArea": { "createNewZoneButton": "Créer une zone", diff --git a/front/src/routes/dashboard/DashboardPage.jsx b/front/src/routes/dashboard/DashboardPage.jsx index 4034176664..07c3627e41 100644 --- a/front/src/routes/dashboard/DashboardPage.jsx +++ b/front/src/routes/dashboard/DashboardPage.jsx @@ -4,6 +4,7 @@ import cx from 'classnames'; import BoxColumns from './BoxColumns'; import EmptyState from './EmptyState'; import SetTabletMode from './SetTabletMode'; +import { JOB_STATUS } from '../../../../server/utils/constants'; import style from './style.css'; @@ -85,6 +86,11 @@ const DashboardPage = ({ children, ...props }) => ( toggleDefineTabletMode={props.toggleDefineTabletMode} defineTabletModeOpened={props.defineTabletModeOpened} /> + {props.duckDbMigrationJob && props.duckDbMigrationJob.status === JOB_STATUS.IN_PROGRESS && ( +
+ +
+ )} {props.dashboardNotConfigured && } {!props.dashboardNotConfigured && } diff --git a/front/src/routes/dashboard/index.js b/front/src/routes/dashboard/index.js index c94d2b590d..1982df66c5 100644 --- a/front/src/routes/dashboard/index.js +++ b/front/src/routes/dashboard/index.js @@ -5,7 +5,7 @@ import { route } from 'preact-router'; import DashboardPage from './DashboardPage'; import GatewayAccountExpired from '../../components/gateway/GatewayAccountExpired'; import actions from '../../actions/dashboard'; -import { WEBSOCKET_MESSAGE_TYPES } from '../../../../server/utils/constants'; +import { JOB_TYPES, WEBSOCKET_MESSAGE_TYPES } from '../../../../server/utils/constants'; import get from 'get-value'; class Dashboard extends Component { @@ -66,6 +66,29 @@ class Dashboard extends Component { } }; + getDuckDbMigrationJob = async () => { + try { + const jobs = await this.props.httpClient.get(`/api/v1/job`, { + type: JOB_TYPES.MIGRATE_SQLITE_TO_DUCKDB, + take: 1 + }); + if (jobs.length > 0) { + this.setState({ + duckDbMigrationJob: jobs[0] + }); + } + } catch (e) { + console.error(e); + } + }; + + jobUpdated = payload => { + const { duckDbMigrationJob } = this.state; + if (payload.id === duckDbMigrationJob.id) { + this.setState({ duckDbMigrationJob: payload }); + } + }; + getCurrentDashboard = async () => { try { await this.setState({ loading: true }); @@ -99,6 +122,7 @@ class Dashboard extends Component { if (this.state.currentDashboardSelector) { await this.getCurrentDashboard(); } + await this.getDuckDbMigrationJob(); }; redirectToDashboard = () => { @@ -202,6 +226,7 @@ class Dashboard extends Component { document.addEventListener('click', this.closeDashboardDropdown, true); this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMED, this.alarmArmed); this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMING, this.alarmArming); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.JOB.UPDATED, this.jobUpdated); this.checkIfFullScreenParameterIsHere(); } @@ -218,6 +243,7 @@ class Dashboard extends Component { document.removeEventListener('click', this.closeDashboardDropdown, true); this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMED, this.alarmArmed); this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.ALARM.ARMING, this.alarmArming); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.JOB.UPDATED, this.jobUpdated); } render( @@ -231,7 +257,8 @@ class Dashboard extends Component { dashboardEditMode, gatewayInstanceNotFound, loading, - browserFullScreenCompatible + browserFullScreenCompatible, + duckDbMigrationJob } ) { const dashboardConfigured = @@ -266,6 +293,7 @@ class Dashboard extends Component { fullScreen={props.fullScreen} hideExitFullScreenButton={props.fullscreen === 'force'} isGladysPlus={isGladysPlus} + duckDbMigrationJob={duckDbMigrationJob} /> ); } diff --git a/front/src/routes/settings/settings-system/SettingsSystemDuckDbMigration.jsx b/front/src/routes/settings/settings-system/SettingsSystemDuckDbMigration.jsx new file mode 100644 index 0000000000..60af85c07f --- /dev/null +++ b/front/src/routes/settings/settings-system/SettingsSystemDuckDbMigration.jsx @@ -0,0 +1,190 @@ +import { connect } from 'unistore/preact'; +import { Component } from 'preact'; +import { Text } from 'preact-i18n'; +import cx from 'classnames'; +import { route } from 'preact-router'; + +class SettingsSystemDuckDbMigration extends Component { + constructor(props) { + super(props); + this.state = { + confirmRestartingMigration: false, + confirmPurgingSQlite: false + }; + } + + getDuckDbMigrationState = async () => { + this.setState({ + loading: true + }); + try { + const migrationState = await this.props.httpClient.get('/api/v1/device/duckdb_migration_state'); + // Format with thousand separator + migrationState.sqlite_db_device_state_count = new Intl.NumberFormat(this.props.user.language).format( + migrationState.sqlite_db_device_state_count + ); + migrationState.duck_db_device_count = new Intl.NumberFormat(this.props.user.language).format( + migrationState.duck_db_device_count + ); + this.setState({ migrationState }); + } catch (e) { + console.error(e); + } + this.setState({ + loading: false + }); + }; + + migrateToDuckDb = async () => { + this.setState({ + loading: true, + confirmRestartingMigration: false + }); + try { + await this.props.httpClient.post('/api/v1/device/migrate_from_sqlite_to_duckdb'); + route('/dashboard/settings/jobs'); + } catch (e) { + console.error(e); + } + this.setState({ + loading: false + }); + }; + + purgeAllSqliteStates = async () => { + this.setState({ + loading: true, + confirmPurgingSQlite: false + }); + try { + await this.props.httpClient.post('/api/v1/device/purge_all_sqlite_state'); + route('/dashboard/settings/jobs'); + } catch (e) { + console.error(e); + } + this.setState({ + loading: false + }); + }; + + togglePurgeConfirmation = () => { + this.setState(prevState => { + return { ...prevState, confirmPurgingSQlite: !prevState.confirmPurgingSQlite }; + }); + }; + + toggleMigrationConfirmation = () => { + this.setState(prevState => { + return { ...prevState, confirmRestartingMigration: !prevState.confirmRestartingMigration }; + }); + }; + + componentDidMount() { + this.getDuckDbMigrationState(); + } + + render({}, { loading, migrationState, confirmRestartingMigration, confirmPurgingSQlite }) { + return ( +
+

+ +

+ +
+
+
+
+

+ +

+
+ +
+ {migrationState && ( +
    +
  • + + + {' '} + {migrationState.is_duck_db_migrated ? ( + + + + ) : ( + + + + )} +
  • +
  • + + + {' '} + : {migrationState.sqlite_db_device_state_count} +
  • +
  • + + + {' '} + : {migrationState.duck_db_device_count} +
  • +
+ )} +
+ +
+

+ {!confirmRestartingMigration ? ( + + ) : ( + + + + + )} +

+
+ +
+

+ {!confirmPurgingSQlite ? ( + + ) : ( + + + + + )} +

+

+ +

+
+
+
+
+ ); + } +} + +export default connect('httpClient,user', null)(SettingsSystemDuckDbMigration); diff --git a/front/src/routes/settings/settings-system/SettingsSystemKeepAggregatedStates.jsx b/front/src/routes/settings/settings-system/SettingsSystemKeepAggregatedStates.jsx deleted file mode 100644 index ded8ce8a76..0000000000 --- a/front/src/routes/settings/settings-system/SettingsSystemKeepAggregatedStates.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import { connect } from 'unistore/preact'; -import { Component } from 'preact'; -import { Text } from 'preact-i18n'; -import { SYSTEM_VARIABLE_NAMES } from '../../../../../server/utils/constants'; -import get from 'get-value'; - -class SettingsSystemKeepAggregatedStates extends Component { - getDeviceAggregateStateHistoryPreference = async () => { - try { - const { value } = await this.props.httpClient.get( - `/api/v1/variable/${SYSTEM_VARIABLE_NAMES.DEVICE_AGGREGATE_STATE_HISTORY_IN_DAYS}` - ); - this.setState({ - deviceAggregateStateHistoryInDays: value - }); - } catch (e) { - console.error(e); - const status = get(e, 'response.status'); - if (status === 404) { - // Default value is -1 - this.setState({ - deviceAggregateStateHistoryInDays: '-1' - }); - } - } - }; - - updateDeviceAggregateStateHistory = async e => { - await this.setState({ - deviceAggregateStateHistoryInDays: e.target.value, - savingDeviceStateHistory: true - }); - try { - await this.props.httpClient.post( - `/api/v1/variable/${SYSTEM_VARIABLE_NAMES.DEVICE_AGGREGATE_STATE_HISTORY_IN_DAYS}`, - { - value: e.target.value - } - ); - } catch (e) { - console.error(e); - } - await this.setState({ - savingDeviceStateHistory: false - }); - }; - - componentDidMount() { - this.getDeviceAggregateStateHistoryPreference(); - } - - render({}, { deviceAggregateStateHistoryInDays }) { - return ( -
-

- -

- -
-
-

- -

- -
-
-
- ); - } -} - -export default connect('httpClient', null)(SettingsSystemKeepAggregatedStates); diff --git a/front/src/routes/settings/settings-system/SettingsSystemPage.jsx b/front/src/routes/settings/settings-system/SettingsSystemPage.jsx index 35901fada5..a9cfa2db58 100644 --- a/front/src/routes/settings/settings-system/SettingsSystemPage.jsx +++ b/front/src/routes/settings/settings-system/SettingsSystemPage.jsx @@ -5,9 +5,9 @@ import SettingsSystemContainers from './SettingsSystemContainers'; import SettingsSystemOperations from './SettingsSystemOperations'; import SettingsSystemTimezone from './SettingsSystemTimezone'; import SettingsSystemKeepDeviceHistory from './SettingsSystemKeepDeviceHistory'; -import SettingsSystemKeepAggregatedStates from './SettingsSystemKeepAggregatedStates'; import SettingsSystemTimeExpiryState from './SettingsSystemTimeExpiryState'; import SettingsSystemDatabaseCleaning from './SettingsSystemDatabaseCleaning'; +import SettingsSystemDuckDbMigration from './SettingsSystemDuckDbMigration'; const SystemPage = ({ children, ...props }) => ( @@ -86,8 +86,8 @@ const SystemPage = ({ children, ...props }) => (
+ -
diff --git a/server/.eslintrc.json b/server/.eslintrc.json index 7caff0e7ec..332e2989a4 100644 --- a/server/.eslintrc.json +++ b/server/.eslintrc.json @@ -2,7 +2,7 @@ "plugins": ["jsdoc", "require-jsdoc", "no-call", "mocha", "promise"], "extends": ["airbnb-base", "plugin:jsdoc/recommended", "prettier"], "parserOptions": { - "ecmaVersion": 2018 + "ecmaVersion": 2023 }, "env": { "node": true diff --git a/server/api/controllers/device.controller.js b/server/api/controllers/device.controller.js index 15687d6988..b04c096e11 100644 --- a/server/api/controllers/device.controller.js +++ b/server/api/controllers/device.controller.js @@ -1,5 +1,5 @@ const asyncMiddleware = require('../middlewares/asyncMiddleware'); -const { EVENTS, ACTIONS, ACTIONS_STATUS } = require('../../utils/constants'); +const { EVENTS, ACTIONS, ACTIONS_STATUS, SYSTEM_VARIABLE_NAMES } = require('../../utils/constants'); module.exports = function DeviceController(gladys) { /** @@ -106,6 +106,37 @@ module.exports = function DeviceController(gladys) { res.json(states); } + /** + * @api {post} /api/v1/device/purge_all_sqlite_state purgeAllSqliteStates + * @apiName purgeAllSqliteStates + * @apiGroup Device + */ + async function purgeAllSqliteStates(req, res) { + gladys.event.emit(EVENTS.DEVICE.PURGE_ALL_SQLITE_STATES); + res.json({ success: true }); + } + + /** + * @api {post} /api/v1/device/migrate_from_sqlite_to_duckdb migrateFromSQLiteToDuckDb + * @apiName migrateFromSQLiteToDuckDb + * @apiGroup Device + */ + async function migrateFromSQLiteToDuckDb(req, res) { + await gladys.variable.destroy(SYSTEM_VARIABLE_NAMES.DUCKDB_MIGRATED); + gladys.event.emit(EVENTS.DEVICE.MIGRATE_FROM_SQLITE_TO_DUCKDB); + res.json({ success: true }); + } + + /** + * @api {get} /api/v1/device/duckdb_migration_state getDuckDbMigrationState + * @apiName getDuckDbMigrationState + * @apiGroup Device + */ + async function getDuckDbMigrationState(req, res) { + const migrationState = await gladys.device.getDuckDbMigrationState(); + res.json(migrationState); + } + return Object.freeze({ create: asyncMiddleware(create), get: asyncMiddleware(get), @@ -115,5 +146,8 @@ module.exports = function DeviceController(gladys) { setValue: asyncMiddleware(setValue), setValueFeature: asyncMiddleware(setValueFeature), getDeviceFeaturesAggregated: asyncMiddleware(getDeviceFeaturesAggregated), + purgeAllSqliteStates: asyncMiddleware(purgeAllSqliteStates), + getDuckDbMigrationState: asyncMiddleware(getDuckDbMigrationState), + migrateFromSQLiteToDuckDb: asyncMiddleware(migrateFromSQLiteToDuckDb), }); }; diff --git a/server/api/routes.js b/server/api/routes.js index f7dc82f14a..6a8561054f 100644 --- a/server/api/routes.js +++ b/server/api/routes.js @@ -199,6 +199,18 @@ function getRoutes(gladys) { authenticated: true, controller: deviceController.get, }, + 'get /api/v1/device/duckdb_migration_state': { + authenticated: true, + controller: deviceController.getDuckDbMigrationState, + }, + 'post /api/v1/device/purge_all_sqlite_state': { + authenticated: true, + controller: deviceController.purgeAllSqliteStates, + }, + 'post /api/v1/device/migrate_from_sqlite_to_duckdb': { + authenticated: true, + controller: deviceController.migrateFromSQLiteToDuckDb, + }, 'get /api/v1/service/:service_name/device': { authenticated: true, controller: deviceController.getDevicesByService, diff --git a/server/config/scheduler-jobs.js b/server/config/scheduler-jobs.js index a5727d78df..0823d4683e 100644 --- a/server/config/scheduler-jobs.js +++ b/server/config/scheduler-jobs.js @@ -11,11 +11,6 @@ const jobs = [ rule: '0 0 4 * * *', // At 4 AM every day event: EVENTS.DEVICE.PURGE_STATES, }, - { - name: 'hourly-device-state-aggregate', - rule: '0 0 * * * *', // every hour - event: EVENTS.DEVICE.CALCULATE_HOURLY_AGGREGATE, - }, { name: 'daily-purge-of-old-jobs', rule: '0 0 22 * * *', // every day at 22:00 diff --git a/server/index.js b/server/index.js index b9c3b526d1..89dc38ed23 100644 --- a/server/index.js +++ b/server/index.js @@ -21,6 +21,26 @@ process.on('uncaughtException', (error, promise) => { logger.error(error); }); +const closeSQLite = async () => { + try { + await db.sequelize.close(); + logger.info('SQLite closed.'); + } catch (e) { + logger.info('SQLite database is probably already closed'); + logger.warn(e); + } +}; + +const closeDuckDB = async () => { + try { + await db.duckDb.close(); + logger.info('DuckDB closed.'); + } catch (e) { + logger.info('DuckDB database is probably already closed'); + logger.warn(e); + } +}; + const shutdown = async (signal) => { logger.info(`${signal} received.`); // We give Gladys 10 seconds to properly shutdown, otherwise we do it @@ -28,13 +48,8 @@ const shutdown = async (signal) => { logger.info('Timeout to shutdown expired, forcing shut down.'); process.exit(); }, 10 * 1000); - logger.info('Closing database connection.'); - try { - await db.sequelize.close(); - } catch (e) { - logger.info('Database is probably already closed'); - logger.warn(e); - } + logger.info('Closing database connections.'); + await Promise.all([closeSQLite(), closeDuckDB()]); process.exit(); }; @@ -45,7 +60,6 @@ process.on('SIGINT', () => shutdown('SIGINT')); // create Gladys object const gladys = Gladys({ jwtSecret: process.env.JWT_SECRET, - disableDeviceStateAggregation: true, }); // start Gladys diff --git a/server/jsconfig.json b/server/jsconfig.json index 517e6ff384..c7cae7bd66 100644 --- a/server/jsconfig.json +++ b/server/jsconfig.json @@ -1,8 +1,10 @@ { "compilerOptions": { "checkJs": true, - "resolveJsonModule": true, - "lib": ["es5", "es2018"] + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "target": "ES2022" }, + "exclude": ["node_modules", "**/node_modules/*"] } diff --git a/server/lib/device/device.calculateAggregate.js b/server/lib/device/device.calculateAggregate.js deleted file mode 100644 index 6f036c435f..0000000000 --- a/server/lib/device/device.calculateAggregate.js +++ /dev/null @@ -1,118 +0,0 @@ -const Promise = require('bluebird'); -const path = require('path'); -const { spawn } = require('child_process'); -const logger = require('../../utils/logger'); - -const { - DEVICE_FEATURE_STATE_AGGREGATE_TYPES, - SYSTEM_VARIABLE_NAMES, - DEFAULT_AGGREGATES_POLICY_IN_DAYS, -} = require('../../utils/constants'); - -const LAST_AGGREGATE_ATTRIBUTES = { - [DEVICE_FEATURE_STATE_AGGREGATE_TYPES.HOURLY]: 'last_hourly_aggregate', - [DEVICE_FEATURE_STATE_AGGREGATE_TYPES.DAILY]: 'last_daily_aggregate', - [DEVICE_FEATURE_STATE_AGGREGATE_TYPES.MONTHLY]: 'last_monthly_aggregate', -}; - -const AGGREGATES_POLICY_RETENTION_VARIABLES = { - [DEVICE_FEATURE_STATE_AGGREGATE_TYPES.HOURLY]: SYSTEM_VARIABLE_NAMES.DEVICE_STATE_HOURLY_AGGREGATES_RETENTION_IN_DAYS, - [DEVICE_FEATURE_STATE_AGGREGATE_TYPES.DAILY]: SYSTEM_VARIABLE_NAMES.DEVICE_STATE_DAILY_AGGREGATES_RETENTION_IN_DAYS, - [DEVICE_FEATURE_STATE_AGGREGATE_TYPES.MONTHLY]: - SYSTEM_VARIABLE_NAMES.DEVICE_STATE_MONTHLY_AGGREGATES_RETENTION_IN_DAYS, -}; - -const AGGREGATE_STATES_PER_INTERVAL = 100; - -const SCRIPT_PATH = path.join(__dirname, 'device.calculcateAggregateChildProcess.js'); - -/** - * @description Calculate Aggregates. - * @param {string} [type] - Type of the aggregate. - * @param {string} [jobId] - Id of the job in db. - * @returns {Promise} - Resolve when finished. - * @example - * await calculateAggregate('monthly'); - */ -async function calculateAggregate(type, jobId) { - logger.info(`Calculating aggregates device feature state for interval ${type}`); - // First we get the retention policy of this aggregates type - let retentionPolicyInDays = await this.variable.getValue(AGGREGATES_POLICY_RETENTION_VARIABLES[type]); - - // if the setting exist, we parse it - // otherwise, we take the default value - if (retentionPolicyInDays) { - retentionPolicyInDays = parseInt(retentionPolicyInDays, 10); - } else { - retentionPolicyInDays = DEFAULT_AGGREGATES_POLICY_IN_DAYS[type]; - } - - logger.debug(`Aggregates device feature state policy = ${retentionPolicyInDays} days`); - - const now = new Date(); - // the aggregate should be from this date at most - const minStartFrom = new Date(new Date().setDate(now.getDate() - retentionPolicyInDays)); - - let endAt; - - if (type === DEVICE_FEATURE_STATE_AGGREGATE_TYPES.MONTHLY) { - endAt = new Date(now.getFullYear(), now.getMonth(), 1); - } else if (type === DEVICE_FEATURE_STATE_AGGREGATE_TYPES.DAILY) { - endAt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0); - } else if (type === DEVICE_FEATURE_STATE_AGGREGATE_TYPES.HOURLY) { - endAt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0); - } - - const params = { - AGGREGATE_STATES_PER_INTERVAL, - DEVICE_FEATURE_STATE_AGGREGATE_TYPES, - LAST_AGGREGATE_ATTRIBUTES, - type, - minStartFrom, - endAt, - jobId, - }; - - const promise = new Promise((resolve, reject) => { - let err = ''; - const childProcess = spawn('node', [SCRIPT_PATH, JSON.stringify(params)]); - - childProcess.stdout.on('data', async (data) => { - const text = data.toString(); - logger.debug(`device.calculateAggregate stdout: ${data}`); - if (text && text.indexOf('updateProgress:') !== -1) { - const position = text.indexOf('updateProgress:'); - const before = text.substr(position + 15); - const splitted = before.split(':'); - const progress = parseInt(splitted[0], 10); - if (!Number.isNaN(progress)) { - await this.job.updateProgress(jobId, progress); - } - } - }); - - childProcess.stderr.on('data', (data) => { - logger.warn(`device.calculateAggregate stderr: ${data}`); - err += data; - }); - - childProcess.on('close', (code) => { - if (code !== 0) { - logger.warn(`device.calculateAggregate: Exiting child process with code ${code}`); - const error = new Error(err); - reject(error); - } else { - logger.info(`device.calculateAggregate: Finishing processing for interval ${type} `); - resolve(); - } - }); - }); - - await promise; - - return null; -} - -module.exports = { - calculateAggregate, -}; diff --git a/server/lib/device/device.calculcateAggregateChildProcess.js b/server/lib/device/device.calculcateAggregateChildProcess.js deleted file mode 100644 index b5828a5c33..0000000000 --- a/server/lib/device/device.calculcateAggregateChildProcess.js +++ /dev/null @@ -1,183 +0,0 @@ -// we allow console.log here because as it's a child process, we'll use -// logger on the parent instance, not here in this child process -/* eslint-disable no-console */ -const Promise = require('bluebird'); -const { LTTB } = require('downsample'); -const { Op } = require('sequelize'); -const uuid = require('uuid'); -const db = require('../../models'); -const { chunk } = require('../../utils/chunks'); - -/** - * @description This function calculate aggregate device values from a child process. - * @param {object} params - Parameters. - * @returns {Promise} - Resolve when finished. - * @example - * await calculateAggregateChildProcess({}); - */ -async function calculateAggregateChildProcess(params) { - const { - AGGREGATE_STATES_PER_INTERVAL, - DEVICE_FEATURE_STATE_AGGREGATE_TYPES, - LAST_AGGREGATE_ATTRIBUTES, - type, - jobId, - } = params; - - const minStartFrom = new Date(params.minStartFrom); - const endAt = new Date(params.endAt); - - // first we get all device features - const deviceFeatures = await db.DeviceFeature.findAll({ - raw: true, - }); - - let previousProgress; - - // foreach device feature - // we use Promise.each to do it one by one to avoid overloading Gladys - await Promise.each(deviceFeatures, async (deviceFeature, index) => { - console.log(`Calculate aggregates values for device feature ${deviceFeature.selector}.`); - - const lastAggregate = deviceFeature[LAST_AGGREGATE_ATTRIBUTES[type]]; - const lastAggregateDate = lastAggregate ? new Date(lastAggregate) : null; - let startFrom; - // if there was an aggregate and it's not older than - // what the retention policy allow - if (lastAggregateDate && lastAggregateDate < minStartFrom) { - console.log(`Choosing minStartFrom, ${lastAggregateDate}, ${minStartFrom}`); - startFrom = minStartFrom; - } else if (lastAggregateDate && lastAggregateDate >= minStartFrom) { - console.log(`Choosing lastAggregate, ${lastAggregateDate}, ${minStartFrom}`); - startFrom = lastAggregateDate; - } else { - console.log(`Choosing Default, ${lastAggregateDate}, ${minStartFrom}`); - startFrom = minStartFrom; - } - - // we get all the data from the last aggregate to beginning of current interval - const queryParams = { - raw: true, - where: { - device_feature_id: deviceFeature.id, - created_at: { - [Op.between]: [startFrom, endAt], - }, - }, - attributes: ['value', 'created_at'], - order: [['created_at', 'ASC']], - }; - - console.log(`Aggregate: Getting data from ${startFrom} to ${endAt}.`); - - const deviceFeatureStates = await db.DeviceFeatureState.findAll(queryParams); - - console.log(`Aggregate: Received ${deviceFeatureStates.length} device feature states.`); - - const deviceFeatureStatePerInterval = new Map(); - - // Group each deviceFeature state by interval (same month, same day, same hour) - deviceFeatureStates.forEach((deviceFeatureState) => { - let options; - if (type === DEVICE_FEATURE_STATE_AGGREGATE_TYPES.MONTHLY) { - options = { - year: 'numeric', - month: '2-digit', - }; - } else if (type === DEVICE_FEATURE_STATE_AGGREGATE_TYPES.DAILY) { - options = { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }; - } else if (type === DEVICE_FEATURE_STATE_AGGREGATE_TYPES.HOURLY) { - options = { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - }; - } - // @ts-ignore - const key = new Date(deviceFeatureState.created_at).toLocaleDateString('en-US', options); - if (!deviceFeatureStatePerInterval.has(key)) { - deviceFeatureStatePerInterval.set(key, []); - } - deviceFeatureStatePerInterval.get(key).push(deviceFeatureState); - }); - - let deviceFeatureStateAggregatesToInsert = []; - - deviceFeatureStatePerInterval.forEach((oneIntervalArray, key) => { - const dataForDownsampling = oneIntervalArray.map((deviceFeatureState) => { - return [new Date(deviceFeatureState.created_at), deviceFeatureState.value]; - }); - - // console.log(`Aggregate: On this interval (${key}), ${oneIntervalArray.length} events found.`); - - // we downsample the data - const downsampled = LTTB(dataForDownsampling, AGGREGATE_STATES_PER_INTERVAL); - - // then we format the data to insert it in the DB - deviceFeatureStateAggregatesToInsert = deviceFeatureStateAggregatesToInsert.concat( - // @ts-ignore - downsampled.map((d) => { - return { - id: uuid.v4(), - type, - device_feature_id: deviceFeature.id, - value: d[1], - created_at: d[0], - updated_at: d[0], - }; - }), - ); - }); - - console.log(`Aggregates: Inserting ${deviceFeatureStateAggregatesToInsert.length} events in database`); - - // we bulk insert the data - if (deviceFeatureStateAggregatesToInsert.length) { - const queryInterface = db.sequelize.getQueryInterface(); - await queryInterface.bulkDelete('t_device_feature_state_aggregate', { - type, - device_feature_id: deviceFeature.id, - created_at: { [Op.between]: [startFrom, endAt] }, - }); - const chunks = chunk(deviceFeatureStateAggregatesToInsert, 500); - console.log(`Aggregates: Inserting the data in ${chunks.length} chunks.`); - await Promise.each(chunks, async (deviceStatesToInsert) => { - await queryInterface.bulkInsert('t_device_feature_state_aggregate', deviceStatesToInsert); - }); - } - await db.DeviceFeature.update( - { [LAST_AGGREGATE_ATTRIBUTES[type]]: endAt }, - { - where: { - id: deviceFeature.id, - }, - }, - ); - const progress = Math.ceil((index * 100) / deviceFeatures.length); - if (previousProgress !== progress && jobId) { - // we need to console.log to give the new progress - // to the main process - console.log(`updateProgress:${progress}:updateProgress`); - previousProgress = progress; - } - }); -} - -const params = JSON.parse(process.argv[2]); - -(async () => { - try { - await db.sequelize.query('PRAGMA journal_mode=WAL;'); - await calculateAggregateChildProcess(params); - await db.sequelize.close(); - process.exit(0); - } catch (e) { - console.error(e); - process.exit(1); - } -})(); diff --git a/server/lib/device/device.destroy.js b/server/lib/device/device.destroy.js index 3d2bc80a0c..32d49c5016 100644 --- a/server/lib/device/device.destroy.js +++ b/server/lib/device/device.destroy.js @@ -1,4 +1,5 @@ const { QueryTypes } = require('sequelize'); +const Promise = require('bluebird'); const { NotFoundError, BadParameters } = require('../../utils/coreErrors'); const { DEVICE_POLL_FREQUENCIES, EVENTS } = require('../../utils/constants'); const db = require('../../models'); @@ -68,6 +69,14 @@ async function destroy(selector) { throw new BadParameters(`${totalNumberOfStates} states in DB. Too much states!`); } + // Delete states from DuckDB + await Promise.each(device.features, async (feature) => { + await db.duckDbWriteConnectionAllAsync( + 'DELETE FROM t_device_feature_state WHERE device_feature_id = ?', + feature.id, + ); + }); + await device.destroy(); // removing from ram cache diff --git a/server/lib/device/device.getDeviceFeaturesAggregates.js b/server/lib/device/device.getDeviceFeaturesAggregates.js index 616ad45d0c..fc6d6e0ac7 100644 --- a/server/lib/device/device.getDeviceFeaturesAggregates.js +++ b/server/lib/device/device.getDeviceFeaturesAggregates.js @@ -1,13 +1,6 @@ -const { Op, fn, col, literal } = require('sequelize'); -const { LTTB } = require('downsample'); -const dayjs = require('dayjs'); -const utc = require('dayjs/plugin/utc'); - const db = require('../../models'); const { NotFoundError } = require('../../utils/coreErrors'); -dayjs.extend(utc); - /** * @description Get all features states aggregates. * @param {string} selector - Device selector. @@ -26,76 +19,33 @@ async function getDeviceFeaturesAggregates(selector, intervalInMinutes, maxState const now = new Date(); const intervalDate = new Date(now.getTime() - intervalInMinutes * 60 * 1000); - const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); - const thirthyHoursAgo = new Date(now.getTime() - 30 * 60 * 60 * 1000); - const tenHoursAgo = new Date(now.getTime() - 10 * 60 * 60 * 1000); - const sixMonthsAgo = new Date(now.getTime() - 6 * 30 * 24 * 60 * 60 * 1000); - - let type; - let groupByFunction; - - if (intervalDate < sixMonthsAgo) { - type = 'monthly'; - groupByFunction = fn('date', col('created_at')); - } else if (intervalDate < fiveDaysAgo) { - type = 'daily'; - groupByFunction = fn('date', col('created_at')); - } else if (intervalDate < thirthyHoursAgo) { - type = 'hourly'; - groupByFunction = fn('strftime', '%Y-%m-%d %H:00:00', col('created_at')); - } else if (intervalDate < tenHoursAgo) { - type = 'hourly'; - // this will extract date rounded to the 5 minutes - // So if the user queries 24h, he'll get 24 * 12 = 288 items - groupByFunction = literal(`datetime(strftime('%s', created_at) - strftime('%s', created_at) % 300, 'unixepoch')`); - } else { - type = 'live'; - } - - let rows; - - if (type === 'live') { - rows = await db.DeviceFeatureState.findAll({ - raw: true, - attributes: ['created_at', 'value'], - where: { - device_feature_id: deviceFeature.id, - created_at: { - [Op.gte]: intervalDate, - }, - }, - }); - } else { - rows = await db.DeviceFeatureStateAggregate.findAll({ - raw: true, - attributes: [ - [groupByFunction, 'created_at'], - [fn('round', fn('avg', col('value')), 2), 'value'], - ], - group: [groupByFunction], - where: { - device_feature_id: deviceFeature.id, - type, - created_at: { - [Op.gte]: intervalDate, - }, - }, - }); - } - - const dataForDownsampling = rows.map((deviceFeatureState) => { - return [dayjs.utc(deviceFeatureState.created_at), deviceFeatureState.value]; - }); - - const downsampled = LTTB(dataForDownsampling, maxStates); - // @ts-ignore - const values = downsampled.map((e) => { - return { - created_at: e[0], - value: e[1], - }; - }); + const values = await db.duckDbReadConnectionAllAsync( + ` + WITH intervals AS ( + SELECT + created_at, + value, + NTILE(?) OVER (ORDER BY created_at) AS interval + FROM + t_device_feature_state + WHERE device_feature_id = ? + AND created_at > ? + ) + SELECT + MIN(created_at) AS created_at, + AVG(value) AS value + FROM + intervals + GROUP BY + interval + ORDER BY + created_at; + `, + maxStates, + deviceFeature.id, + intervalDate, + ); return { device: { diff --git a/server/lib/device/device.getDuckDbMigrationState.js b/server/lib/device/device.getDuckDbMigrationState.js new file mode 100644 index 0000000000..1b50c23921 --- /dev/null +++ b/server/lib/device/device.getDuckDbMigrationState.js @@ -0,0 +1,25 @@ +const db = require('../../models'); +const { SYSTEM_VARIABLE_NAMES } = require('../../utils/constants'); + +/** + * @description GetDuckDbMigrationState. + * @example await getDuckDbMigrationState(); + * @returns {Promise} Resolve with current migration state. + */ +async function getDuckDbMigrationState() { + const isDuckDbMigrated = await this.variable.getValue(SYSTEM_VARIABLE_NAMES.DUCKDB_MIGRATED); + const [{ count: duckDbDeviceStateCount }] = await db.duckDbReadConnectionAllAsync(` + SELECT COUNT(value) as count FROM t_device_feature_state; + `); + const sqliteDeviceStateCount = await db.DeviceFeatureState.count(); + + return { + is_duck_db_migrated: isDuckDbMigrated === 'true', + duck_db_device_count: Number(duckDbDeviceStateCount), + sqlite_db_device_state_count: sqliteDeviceStateCount, + }; +} + +module.exports = { + getDuckDbMigrationState, +}; diff --git a/server/lib/device/device.init.js b/server/lib/device/device.init.js index c4a113e8d4..f67b3190a3 100644 --- a/server/lib/device/device.init.js +++ b/server/lib/device/device.init.js @@ -1,15 +1,14 @@ const db = require('../../models'); -const { EVENTS } = require('../../utils/constants'); const logger = require('../../utils/logger'); /** * @description Init devices in local RAM. - * @param {boolean} startDeviceStateAggregate - Start the device aggregate task. + * @param {boolean} startDuckDbMigration - Should start DuckDB migration. * @returns {Promise} Resolve with inserted devices. * @example * gladys.device.init(); */ -async function init(startDeviceStateAggregate = true) { +async function init(startDuckDbMigration = true) { // load all devices in RAM const devices = await db.Device.findAll({ include: [ @@ -38,12 +37,11 @@ async function init(startDeviceStateAggregate = true) { this.brain.addNamedEntity('device', plainDevice.selector, plainDevice.name); return plainDevice; }); - if (startDeviceStateAggregate) { - // calculate aggregate data for device states - this.eventManager.emit(EVENTS.DEVICE.CALCULATE_HOURLY_AGGREGATE); - } // setup polling for device who need polling this.setupPoll(); + if (startDuckDbMigration) { + this.migrateFromSQLiteToDuckDb(); + } return plainDevices; } diff --git a/server/lib/device/device.migrateFromSQLiteToDuckDb.js b/server/lib/device/device.migrateFromSQLiteToDuckDb.js new file mode 100644 index 0000000000..36969b6314 --- /dev/null +++ b/server/lib/device/device.migrateFromSQLiteToDuckDb.js @@ -0,0 +1,85 @@ +const Promise = require('bluebird'); +const { Op } = require('sequelize'); +const db = require('../../models'); +const logger = require('../../utils/logger'); +const { SYSTEM_VARIABLE_NAMES } = require('../../utils/constants'); + +const migrateStateRecursive = async (deviceFeatureId, condition) => { + logger.info(`DuckDB : Migrating device feature = ${deviceFeatureId}, offset = ${condition.offset}`); + // Get all device feature state in SQLite that match the condition + const states = await db.DeviceFeatureState.findAll(condition); + logger.info(`DuckDB : Device feature = ${deviceFeatureId} has ${states.length} states to migrate.`); + if (states.length === 0) { + return null; + } + await db.duckDbBatchInsertState(deviceFeatureId, states); + const newCondition = { + ...condition, + offset: condition.offset + condition.limit, + }; + // We see if there are other states + await migrateStateRecursive(deviceFeatureId, newCondition); + return null; +}; + +/** + * @description Migrate all states from SQLite to DuckDB. + * @param {string} jobId - ID of the job. + * @param {number} duckDbMigrationPageLimit - Number of rows to migrate in one shot. + * @returns {Promise} Resolve when finished. + * @example migrateFromSQLiteToDuckDb(); + */ +async function migrateFromSQLiteToDuckDb(jobId, duckDbMigrationPageLimit = 40000) { + // Check if migration was already executed + const isDuckDbMigrated = await this.variable.getValue(SYSTEM_VARIABLE_NAMES.DUCKDB_MIGRATED); + if (isDuckDbMigrated === 'true') { + logger.info('DuckDB : Already migrated from SQLite. Not migrating.'); + return null; + } + logger.info('DuckDB: Migrating data from SQLite'); + const oldestStateInDuckDBPerDeviceFeatureId = await db.duckDbReadConnectionAllAsync(` + SELECT + MIN(created_at) as created_at, + device_feature_id + FROM t_device_feature_state + GROUP BY device_feature_id; + `); + logger.info( + `DuckDB: Found ${oldestStateInDuckDBPerDeviceFeatureId.length} already migrated device features in DuckDB.`, + ); + const deviceFeatures = await db.DeviceFeature.findAll(); + logger.info(`DuckDB: Migrating ${deviceFeatures.length} device features`); + await Promise.mapSeries(deviceFeatures, async (deviceFeature, deviceFeatureIndex) => { + const currentDeviceFeatureOldestStateInDuckDB = oldestStateInDuckDBPerDeviceFeatureId.find( + (s) => s.device_feature_id === deviceFeature.id, + ); + const condition = { + raw: true, + attributes: ['value', 'created_at'], + where: { + device_feature_id: deviceFeature.id, + }, + order: [['created_at', 'DESC']], + limit: duckDbMigrationPageLimit, + offset: 0, + }; + if (currentDeviceFeatureOldestStateInDuckDB) { + condition.where.created_at = { + [Op.lt]: currentDeviceFeatureOldestStateInDuckDB.created_at, + }; + } + await migrateStateRecursive(deviceFeature.id, condition); + const newProgressPercent = Math.round((deviceFeatureIndex * 100) / deviceFeatures.length); + await this.job.updateProgress(jobId, newProgressPercent); + }); + + logger.info(`DuckDB: Finished migrating DuckDB.`); + + // Save that the migration was executed + await this.variable.setValue(SYSTEM_VARIABLE_NAMES.DUCKDB_MIGRATED, 'true'); + return null; +} + +module.exports = { + migrateFromSQLiteToDuckDb, +}; diff --git a/server/lib/device/device.onHourlyDeviceAggregateEvent.js b/server/lib/device/device.onHourlyDeviceAggregateEvent.js deleted file mode 100644 index 24818e182b..0000000000 --- a/server/lib/device/device.onHourlyDeviceAggregateEvent.js +++ /dev/null @@ -1,38 +0,0 @@ -const { JOB_TYPES } = require('../../utils/constants'); -const logger = require('../../utils/logger'); - -/** - * @description It's time to do the daily device state aggregate. - * @example - * onHourlyDeviceAggregateEvent() - */ -async function onHourlyDeviceAggregateEvent() { - const startHourlyAggregate = this.job.wrapper(JOB_TYPES.HOURLY_DEVICE_STATE_AGGREGATE, async (jobId) => { - await this.calculateAggregate('hourly', jobId); - }); - const startDailyAggregate = this.job.wrapper(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE, async (jobId) => { - await this.calculateAggregate('daily', jobId); - }); - const startMonthlyAggregate = this.job.wrapper(JOB_TYPES.MONTHLY_DEVICE_STATE_AGGREGATE, async (jobId) => { - await this.calculateAggregate('monthly', jobId); - }); - try { - await startHourlyAggregate(); - } catch (e) { - logger.error(e); - } - try { - await startDailyAggregate(); - } catch (e) { - logger.error(e); - } - try { - await startMonthlyAggregate(); - } catch (e) { - logger.error(e); - } -} - -module.exports = { - onHourlyDeviceAggregateEvent, -}; diff --git a/server/lib/device/device.onPurgeStatesEvent.js b/server/lib/device/device.onPurgeStatesEvent.js index 17cb296b67..87ed7bb0af 100644 --- a/server/lib/device/device.onPurgeStatesEvent.js +++ b/server/lib/device/device.onPurgeStatesEvent.js @@ -8,7 +8,6 @@ const { JOB_TYPES } = require('../../utils/constants'); async function onPurgeStatesEvent() { const purgeAllStates = this.job.wrapper(JOB_TYPES.DEVICE_STATES_PURGE, async () => { await this.purgeStates(); - await this.purgeAggregateStates(); }); await purgeAllStates(); } diff --git a/server/lib/device/device.purgeAggregateStates.js b/server/lib/device/device.purgeAggregateStates.js deleted file mode 100644 index e3313db178..0000000000 --- a/server/lib/device/device.purgeAggregateStates.js +++ /dev/null @@ -1,44 +0,0 @@ -const { Op } = require('sequelize'); -const db = require('../../models'); -const logger = require('../../utils/logger'); -const { SYSTEM_VARIABLE_NAMES } = require('../../utils/constants'); - -/** - * @description Purge device aggregate states. - * @returns {Promise} Resolve when finished. - * @example - * device.purgeAggregateStates(); - */ -async function purgeAggregateStates() { - logger.debug('Purging device feature aggregate states...'); - const deviceAggregateStateHistoryInDays = await this.variable.getValue( - SYSTEM_VARIABLE_NAMES.DEVICE_AGGREGATE_STATE_HISTORY_IN_DAYS, - ); - const deviceAggregateStateHistoryInDaysInt = parseInt(deviceAggregateStateHistoryInDays, 10); - if (Number.isNaN(deviceAggregateStateHistoryInDaysInt)) { - logger.debug('Not purging device feature aggregate states, deviceAggregateStateHistoryInDays is not an integer.'); - return Promise.resolve(false); - } - if (deviceAggregateStateHistoryInDaysInt === -1) { - logger.debug('Not purging device feature aggregate states, deviceAggregateStateHistoryInDays = -1'); - return Promise.resolve(false); - } - const queryInterface = db.sequelize.getQueryInterface(); - const now = new Date().getTime(); - // all date before this timestamp will be removed - const timstampLimit = now - deviceAggregateStateHistoryInDaysInt * 24 * 60 * 60 * 1000; - const dateLimit = new Date(timstampLimit); - logger.info( - `Purging device feature states of the last ${deviceAggregateStateHistoryInDaysInt} days. States older than ${dateLimit} will be purged.`, - ); - await queryInterface.bulkDelete('t_device_feature_state_aggregate', { - created_at: { - [Op.lte]: dateLimit, - }, - }); - return true; -} - -module.exports = { - purgeAggregateStates, -}; diff --git a/server/lib/device/device.purgeAllSqliteStates.js b/server/lib/device/device.purgeAllSqliteStates.js new file mode 100644 index 0000000000..72b05bef14 --- /dev/null +++ b/server/lib/device/device.purgeAllSqliteStates.js @@ -0,0 +1,104 @@ +const { QueryTypes } = require('sequelize'); +const Promise = require('bluebird'); +const db = require('../../models'); +const logger = require('../../utils/logger'); + +/** + * @description Purge all SQLite states. + * @param {string} jobId - Id of the job. + * @returns {Promise} Resolve when finished. + * @example + * device.purgeAllSqliteStates('d47b481b-a7be-4224-9850-313cdb8a4065'); + */ +async function purgeAllSqliteStates(jobId) { + if (this.purgeAllSQliteStatesInProgress) { + logger.info(`Not purging all SQlite states, a purge is already in progress`); + return null; + } + logger.info(`Purging all SQlite states`); + this.purgeAllSQliteStatesInProgress = true; + + try { + const numberOfDeviceFeatureStateToDelete = await db.DeviceFeatureState.count(); + const numberOfDeviceFeatureStateAggregateToDelete = await db.DeviceFeatureStateAggregate.count(); + + logger.info( + `Purging All SQLite states: ${numberOfDeviceFeatureStateToDelete} states & ${numberOfDeviceFeatureStateAggregateToDelete} aggregates to delete.`, + ); + + const numberOfIterationsStates = Math.ceil( + numberOfDeviceFeatureStateToDelete / this.STATES_TO_PURGE_PER_DEVICE_FEATURE_CLEAN_BATCH, + ); + const iterator = [...Array(numberOfIterationsStates)]; + + const numberOfIterationsStatesAggregates = Math.ceil( + numberOfDeviceFeatureStateAggregateToDelete / this.STATES_TO_PURGE_PER_DEVICE_FEATURE_CLEAN_BATCH, + ); + const iteratorAggregates = [...Array(numberOfIterationsStatesAggregates)]; + + const total = numberOfIterationsStates + numberOfIterationsStatesAggregates; + let currentBatch = 0; + let currentProgressPercent = 0; + + // We only save progress to DB if it changed + // Because saving progress is expensive (DB write + Websocket call) + const updateProgressIfNeeded = async () => { + currentBatch += 1; + const newProgressPercent = Math.round((currentBatch * 100) / total); + if (currentProgressPercent !== newProgressPercent) { + currentProgressPercent = newProgressPercent; + await this.job.updateProgress(jobId, currentProgressPercent); + } + }; + + await Promise.each(iterator, async () => { + await db.sequelize.query( + ` + DELETE FROM t_device_feature_state WHERE id IN ( + SELECT id FROM t_device_feature_state + LIMIT :limit + ); + `, + { + replacements: { + limit: this.STATES_TO_PURGE_PER_DEVICE_FEATURE_CLEAN_BATCH, + }, + type: QueryTypes.SELECT, + }, + ); + await updateProgressIfNeeded(); + await Promise.delay(this.WAIT_TIME_BETWEEN_DEVICE_FEATURE_CLEAN_BATCH); + }); + + await Promise.each(iteratorAggregates, async () => { + await db.sequelize.query( + ` + DELETE FROM t_device_feature_state_aggregate WHERE id IN ( + SELECT id FROM t_device_feature_state_aggregate + LIMIT :limit + ); + `, + { + replacements: { + limit: this.STATES_TO_PURGE_PER_DEVICE_FEATURE_CLEAN_BATCH, + }, + type: QueryTypes.SELECT, + }, + ); + await updateProgressIfNeeded(); + await Promise.delay(this.WAIT_TIME_BETWEEN_DEVICE_FEATURE_CLEAN_BATCH); + }); + this.purgeAllSQliteStatesInProgress = false; + return { + numberOfDeviceFeatureStateToDelete, + numberOfDeviceFeatureStateAggregateToDelete, + }; + } catch (e) { + this.purgeAllSQliteStatesInProgress = false; + throw e; + } +} + +module.exports = { + purgeAllSqliteStates, +}; diff --git a/server/lib/device/device.purgeStates.js b/server/lib/device/device.purgeStates.js index cd95f8e9e5..a0e8985d6f 100644 --- a/server/lib/device/device.purgeStates.js +++ b/server/lib/device/device.purgeStates.js @@ -1,4 +1,3 @@ -const { Op } = require('sequelize'); const db = require('../../models'); const logger = require('../../utils/logger'); const { SYSTEM_VARIABLE_NAMES } = require('../../utils/constants'); @@ -21,7 +20,6 @@ async function purgeStates() { logger.debug('Not purging device feature states, deviceStateHistoryInDays = -1'); return Promise.resolve(false); } - const queryInterface = db.sequelize.getQueryInterface(); const now = new Date().getTime(); // all date before this timestamp will be removed const timstampLimit = now - deviceStateHistoryInDaysInt * 24 * 60 * 60 * 1000; @@ -29,11 +27,12 @@ async function purgeStates() { logger.info( `Purging device feature states of the last ${deviceStateHistoryInDaysInt} days. States older than ${dateLimit} will be purged.`, ); - await queryInterface.bulkDelete('t_device_feature_state', { - created_at: { - [Op.lte]: dateLimit, - }, - }); + await db.duckDbWriteConnectionAllAsync( + ` + DELETE FROM t_device_feature_state WHERE created_at < ? + `, + dateLimit, + ); return true; } diff --git a/server/lib/device/device.saveHistoricalState.js b/server/lib/device/device.saveHistoricalState.js index 4e15c2484f..ab606a3311 100644 --- a/server/lib/device/device.saveHistoricalState.js +++ b/server/lib/device/device.saveHistoricalState.js @@ -2,6 +2,7 @@ const Joi = require('joi'); const { Op } = require('sequelize'); const db = require('../../models'); const logger = require('../../utils/logger'); +const { formatDateInUTC } = require('../../utils/date'); const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../utils/constants'); const { BadParameters } = require('../../utils/coreErrors'); @@ -76,24 +77,24 @@ async function saveHistoricalState(deviceFeature, newValue, newValueCreatedAt) { } // if the deviceFeature should keep history, we save a new deviceFeatureState if (deviceFeature.keep_history) { - const valueAlreadyExistInDb = await db.DeviceFeatureState.findOne({ - where: { - device_feature_id: deviceFeature.id, - value: newValue, - created_at: newValueCreatedAtDate, - }, - }); + const valueAlreadyExistInDb = await db.duckDbReadConnectionAllAsync( + ` + SELECT * + FROM t_device_feature_state + WHERE device_feature_id = ? + AND value = ? + AND created_at = ? + `, + deviceFeature.id, + newValue, + formatDateInUTC(newValueCreatedAtDate), + ); // if the value already exist in the DB, don't re-create it - if (valueAlreadyExistInDb !== null) { + if (valueAlreadyExistInDb.length > 0) { logger.debug('device.saveHistoricalState: Not saving value in history, value already exists'); return; } - await db.DeviceFeatureState.create({ - device_feature_id: deviceFeature.id, - value: newValue, - created_at: newValueCreatedAtDate, - updated_at: newValueCreatedAtDate, - }); + await db.duckDbInsertState(deviceFeature.id, newValue, newValueCreatedAtDate); // We need to update last aggregate value // So that aggregate is calculated again const lastDayOfPreviousMonth = new Date( diff --git a/server/lib/device/device.saveState.js b/server/lib/device/device.saveState.js index 3bfa09b7b3..46e112e3cb 100644 --- a/server/lib/device/device.saveState.js +++ b/server/lib/device/device.saveState.js @@ -41,10 +41,7 @@ async function saveState(deviceFeature, newValue) { ); // if the deviceFeature should keep history, we save a new deviceFeatureState if (deviceFeature.keep_history) { - await db.DeviceFeatureState.create({ - device_feature_id: deviceFeature.id, - value: newValue, - }); + await db.duckDbInsertState(deviceFeature.id, newValue, now); } // send websocket event diff --git a/server/lib/device/index.js b/server/lib/device/index.js index 069948d22a..f69c3aca2f 100644 --- a/server/lib/device/index.js +++ b/server/lib/device/index.js @@ -12,17 +12,14 @@ const { add } = require('./device.add'); const { addFeature } = require('./device.addFeature'); const { addParam } = require('./device.addParam'); const { create } = require('./device.create'); -const { calculateAggregate } = require('./device.calculateAggregate'); const { destroy } = require('./device.destroy'); const { init } = require('./device.init'); const { get } = require('./device.get'); const { getBySelector } = require('./device.getBySelector'); const { getDeviceFeaturesAggregates } = require('./device.getDeviceFeaturesAggregates'); const { getDeviceFeaturesAggregatesMulti } = require('./device.getDeviceFeaturesAggregatesMulti'); -const { onHourlyDeviceAggregateEvent } = require('./device.onHourlyDeviceAggregateEvent'); const { onPurgeStatesEvent } = require('./device.onPurgeStatesEvent'); const { purgeStates } = require('./device.purgeStates'); -const { purgeAggregateStates } = require('./device.purgeAggregateStates'); const { purgeStatesByFeatureId } = require('./device.purgeStatesByFeatureId'); const { poll } = require('./device.poll'); const { pollAll } = require('./device.pollAll'); @@ -35,6 +32,9 @@ const { setupPoll } = require('./device.setupPoll'); const { newStateEvent } = require('./device.newStateEvent'); const { notify } = require('./device.notify'); const { checkBatteries } = require('./device.checkBatteries'); +const { migrateFromSQLiteToDuckDb } = require('./device.migrateFromSQLiteToDuckDb'); +const { getDuckDbMigrationState } = require('./device.getDuckDbMigrationState'); +const { purgeAllSqliteStates } = require('./device.purgeAllSqliteStates'); const DeviceManager = function DeviceManager( eventManager, @@ -72,39 +72,51 @@ const DeviceManager = function DeviceManager( this.purgeStatesByFeatureId.bind(this), ); + this.purgeAllSqliteStates = this.job.wrapper( + JOB_TYPES.DEVICE_STATES_PURGE_ALL_SQLITE_STATES, + this.purgeAllSqliteStates.bind(this), + ); + this.devicesByPollFrequency = {}; + + this.migrateFromSQLiteToDuckDb = this.job.wrapper( + JOB_TYPES.MIGRATE_SQLITE_TO_DUCKDB, + this.migrateFromSQLiteToDuckDb.bind(this), + ); + // listen to events this.eventManager.on(EVENTS.DEVICE.NEW_STATE, this.newStateEvent.bind(this)); this.eventManager.on(EVENTS.DEVICE.NEW, eventFunctionWrapper(this.create.bind(this))); this.eventManager.on(EVENTS.DEVICE.ADD_FEATURE, eventFunctionWrapper(this.addFeature.bind(this))); this.eventManager.on(EVENTS.DEVICE.ADD_PARAM, eventFunctionWrapper(this.addParam.bind(this))); this.eventManager.on(EVENTS.DEVICE.PURGE_STATES, eventFunctionWrapper(this.onPurgeStatesEvent.bind(this))); - this.eventManager.on( - EVENTS.DEVICE.CALCULATE_HOURLY_AGGREGATE, - eventFunctionWrapper(this.onHourlyDeviceAggregateEvent.bind(this)), - ); this.eventManager.on( EVENTS.DEVICE.PURGE_STATES_SINGLE_FEATURE, eventFunctionWrapper(this.purgeStatesByFeatureId.bind(this)), ); this.eventManager.on(EVENTS.DEVICE.CHECK_BATTERIES, eventFunctionWrapper(this.checkBatteries.bind(this))); + this.eventManager.on( + EVENTS.DEVICE.MIGRATE_FROM_SQLITE_TO_DUCKDB, + eventFunctionWrapper(this.migrateFromSQLiteToDuckDb.bind(this)), + ); + this.eventManager.on( + EVENTS.DEVICE.PURGE_ALL_SQLITE_STATES, + eventFunctionWrapper(this.purgeAllSqliteStates.bind(this)), + ); }; DeviceManager.prototype.add = add; DeviceManager.prototype.addFeature = addFeature; DeviceManager.prototype.addParam = addParam; DeviceManager.prototype.create = create; -DeviceManager.prototype.calculateAggregate = calculateAggregate; DeviceManager.prototype.destroy = destroy; DeviceManager.prototype.init = init; DeviceManager.prototype.get = get; DeviceManager.prototype.getBySelector = getBySelector; DeviceManager.prototype.getDeviceFeaturesAggregates = getDeviceFeaturesAggregates; DeviceManager.prototype.getDeviceFeaturesAggregatesMulti = getDeviceFeaturesAggregatesMulti; -DeviceManager.prototype.onHourlyDeviceAggregateEvent = onHourlyDeviceAggregateEvent; DeviceManager.prototype.onPurgeStatesEvent = onPurgeStatesEvent; DeviceManager.prototype.purgeStates = purgeStates; -DeviceManager.prototype.purgeAggregateStates = purgeAggregateStates; DeviceManager.prototype.purgeStatesByFeatureId = purgeStatesByFeatureId; DeviceManager.prototype.poll = poll; DeviceManager.prototype.pollAll = pollAll; @@ -117,5 +129,8 @@ DeviceManager.prototype.setupPoll = setupPoll; DeviceManager.prototype.setValue = setValue; DeviceManager.prototype.notify = notify; DeviceManager.prototype.checkBatteries = checkBatteries; +DeviceManager.prototype.migrateFromSQLiteToDuckDb = migrateFromSQLiteToDuckDb; +DeviceManager.prototype.getDuckDbMigrationState = getDuckDbMigrationState; +DeviceManager.prototype.purgeAllSqliteStates = purgeAllSqliteStates; module.exports = DeviceManager; diff --git a/server/lib/gateway/gateway.backup.js b/server/lib/gateway/gateway.backup.js index ae4e9fa27f..c149e88078 100644 --- a/server/lib/gateway/gateway.backup.js +++ b/server/lib/gateway/gateway.backup.js @@ -42,14 +42,17 @@ async function backup(jobId) { const now = new Date(); const date = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}-${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}`; - const backupFileName = `${BACKUP_NAME_BASE}-${date}.db`; - const backupFilePath = path.join(this.config.backupsFolder, backupFileName); - const compressedBackupFilePath = `${backupFilePath}.gz`; + const sqliteBackupFileName = `${BACKUP_NAME_BASE}-${date}.db`; + const sqliteBackupFilePath = path.join(this.config.backupsFolder, sqliteBackupFileName); + const duckDbBackupFolder = `${BACKUP_NAME_BASE}_${date}_parquet_folder`; + const duckDbBackupFolderPath = path.join(this.config.backupsFolder, duckDbBackupFolder); + const compressedBackupFileName = `${BACKUP_NAME_BASE}-${date}.tar.gz`; + const compressedBackupFilePath = path.join(this.config.backupsFolder, compressedBackupFileName); const encryptedBackupFilePath = `${compressedBackupFilePath}.enc`; // we ensure the backup folder exists await fse.ensureDir(this.config.backupsFolder); // we lock the database - logger.info(`Gateway backup: Locking Database`); + logger.info(`Gateway backup: Locking SQLite Database`); // It's possible to get "Cannot start a transaction within a transaction" errors // So we might want to retry this part a few times await retry(async (bail, attempt) => { @@ -58,18 +61,28 @@ async function backup(jobId) { // we delete old backups await fse.emptyDir(this.config.backupsFolder); // We backup database - logger.info(`Starting Gateway backup in folder ${backupFilePath}`); - await exec(`sqlite3 ${this.config.storage} ".backup '${backupFilePath}'"`); + logger.info(`Starting Gateway backup in folder ${sqliteBackupFilePath}`); + await exec(`sqlite3 ${this.config.storage} ".backup '${sqliteBackupFilePath}'"`); logger.info(`Gateway backup: Unlocking Database`); }); }, SQLITE_BACKUP_RETRY_OPTIONS); await this.job.updateProgress(jobId, 10); - const fileInfos = await fsPromise.stat(backupFilePath); + const fileInfos = await fsPromise.stat(sqliteBackupFilePath); const fileSizeMB = Math.round(fileInfos.size / 1024 / 1024); - logger.info(`Gateway backup : Success! File size is ${fileSizeMB}mb.`); + logger.info(`Gateway backup : SQLite file size is ${fileSizeMB}mb.`); + logger.info(`Gateway backup : Backing up DuckDB into a Parquet folder ${duckDbBackupFolderPath}`); + // DuckDB backup to parquet file + await db.duckDbWriteConnectionAllAsync( + ` EXPORT DATABASE '${duckDbBackupFolderPath}' ( + FORMAT PARQUET, + COMPRESSION GZIP + )`, + ); // compress backup - logger.info(`Gateway backup: Gzipping backup`); - await exec(`gzip ${backupFilePath}`); + logger.info(`Gateway backup: Compressing backup`); + await exec( + `cd ${this.config.backupsFolder} && tar -czvf ${compressedBackupFileName} ${sqliteBackupFileName} ${duckDbBackupFolder}`, + ); await this.job.updateProgress(jobId, 20); // encrypt backup logger.info(`Gateway backup: Encrypting backup`); @@ -82,7 +95,7 @@ async function backup(jobId) { logger.info( `Gateway backup: Uploading backup, size of encrypted backup = ${Math.round( encryptedFileInfos.size / 1024 / 1024, - )}mb.`, + )}mb. Path = ${encryptedBackupFilePath}`, ); const initializeBackupResponse = await this.gladysGatewayClient.initializeMultiPartBackup({ file_size: encryptedFileInfos.size, diff --git a/server/lib/gateway/gateway.downloadBackup.js b/server/lib/gateway/gateway.downloadBackup.js index 91e41ae28e..99b2cb0f74 100644 --- a/server/lib/gateway/gateway.downloadBackup.js +++ b/server/lib/gateway/gateway.downloadBackup.js @@ -20,17 +20,21 @@ async function downloadBackup(fileUrl) { if (encryptKey === null) { throw new NotFoundError('GLADYS_GATEWAY_BACKUP_KEY_NOT_FOUND'); } - // extract file name + // Extract file name const fileWithoutSignedParams = fileUrl.split('?')[0]; - const encryptedBackupName = path.basename(fileWithoutSignedParams, '.enc'); const restoreFolderPath = path.join(this.config.backupsFolder, RESTORE_FOLDER); - const encryptedBackupFilePath = path.join(restoreFolderPath, `${encryptedBackupName}.enc`); - const compressedBackupFilePath = path.join(restoreFolderPath, `${encryptedBackupName}.db.gz`); - const backupFilePath = path.join(restoreFolderPath, `${encryptedBackupName}.db`); // we ensure the restore backup folder exists await fse.ensureDir(restoreFolderPath); // we empty the restore backup folder await fse.emptyDir(restoreFolderPath); + + const encryptedBackupName = path.basename(fileWithoutSignedParams, '.enc'); + const encryptedBackupFilePath = path.join(restoreFolderPath, `${encryptedBackupName}.enc`); + const compressedBackupFilePath = path.join(restoreFolderPath, `${encryptedBackupName}.gz`); + + let duckDbBackupFolderPath = null; + let sqliteBackupFilePath = null; + // we create a stream const writeStream = fs.createWriteStream(encryptedBackupFilePath); // and download the backup file @@ -41,19 +45,39 @@ async function downloadBackup(fileUrl) { await exec( `openssl enc -aes-256-cbc -pass pass:${encryptKey} -d -in ${encryptedBackupFilePath} -out ${compressedBackupFilePath}`, ); - // decompress backup - await exec(`gzip -d ${compressedBackupFilePath}`); + + try { + logger.info(`Trying to restore the backup new style (DuckDB)`); + await exec(`tar -xzvf ${compressedBackupFilePath} -C ${restoreFolderPath}`); + logger.info("Extracting worked. It's a DuckDB export."); + const itemsInFolder = await fse.readdir(restoreFolderPath); + sqliteBackupFilePath = path.join( + restoreFolderPath, + itemsInFolder.find((i) => i.endsWith('.db')), + ); + duckDbBackupFolderPath = path.join( + restoreFolderPath, + itemsInFolder.find((i) => i.endsWith('_parquet_folder')), + ); + } catch (e) { + logger.info(`Extracting failed using new strategy (Error: ${e})`); + logger.info(`Restoring using old backup strategy (SQLite only)`); + sqliteBackupFilePath = path.join(restoreFolderPath, `${encryptedBackupName}.db`); + await exec(`gzip -dc ${compressedBackupFilePath} > ${sqliteBackupFilePath}`); + } // done! logger.info(`Gladys backup downloaded with success.`); // send websocket event to indicate that this.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.BACKUP.DOWNLOADED, payload: { - backupFilePath, + sqliteBackupFilePath, + duckDbBackupFolderPath, }, }); return { - backupFilePath, + sqliteBackupFilePath, + duckDbBackupFolderPath, }; } diff --git a/server/lib/gateway/gateway.getLatestGladysVersion.js b/server/lib/gateway/gateway.getLatestGladysVersion.js index 411f682aeb..91e5df7747 100644 --- a/server/lib/gateway/gateway.getLatestGladysVersion.js +++ b/server/lib/gateway/gateway.getLatestGladysVersion.js @@ -9,14 +9,16 @@ const db = require('../../models'); async function getLatestGladysVersion() { const systemInfos = await this.system.getInfos(); const clientId = await this.variable.getValue('GLADYS_INSTANCE_CLIENT_ID'); - const deviceStateCount = await db.DeviceFeatureState.count(); + const [{ count: deviceStateCount }] = await db.duckDbReadConnectionAllAsync(` + SELECT COUNT(value) as count FROM t_device_feature_state; + `); const serviceUsage = await this.serviceManager.getUsage(); const params = { system: systemInfos.platform, node_version: systemInfos.nodejs_version, is_docker: systemInfos.is_docker, client_id: clientId, - device_state_count: deviceStateCount, + device_state_count: Number(deviceStateCount), integrations: serviceUsage, }; const latestGladysVersion = await this.gladysGatewayClient.getLatestGladysVersion(systemInfos.gladys_version, params); diff --git a/server/lib/gateway/gateway.restoreBackup.js b/server/lib/gateway/gateway.restoreBackup.js index fd7b25af1a..5a89e7d74b 100644 --- a/server/lib/gateway/gateway.restoreBackup.js +++ b/server/lib/gateway/gateway.restoreBackup.js @@ -1,26 +1,30 @@ const fse = require('fs-extra'); const sqlite3 = require('sqlite3'); +const duckdb = require('duckdb'); const { promisify } = require('util'); + +const db = require('../../models'); const logger = require('../../utils/logger'); const { exec } = require('../../utils/childProcess'); const { NotFoundError } = require('../../utils/coreErrors'); /** * @description Replace the local sqlite database with a backup. - * @param {string} backupFilePath - The path of the backup. + * @param {string} sqliteBackupFilePath - The path of the sqlite backup. + * @param {string} [duckDbBackupFolderPath] - The path of the DuckDB backup folder. * @example * restoreBackup('/backup.db'); */ -async function restoreBackup(backupFilePath) { - logger.info(`Restoring back up ${backupFilePath}`); +async function restoreBackup(sqliteBackupFilePath, duckDbBackupFolderPath) { + logger.info(`Restoring back up ${sqliteBackupFilePath} / ${duckDbBackupFolderPath}`); // ensure that the file exists - const exists = await fse.pathExists(backupFilePath); - if (!exists) { + const sqliteBackupExists = await fse.pathExists(sqliteBackupFilePath); + if (!sqliteBackupExists) { throw new NotFoundError('BACKUP_NOT_FOUND'); } logger.info('Testing if backup is a valid Gladys SQLite database.'); // Testing if the backup is a valid backup - const potentialNewDb = new sqlite3.Database(backupFilePath); + const potentialNewDb = new sqlite3.Database(sqliteBackupFilePath); const getAsync = promisify(potentialNewDb.get.bind(potentialNewDb)); const closeAsync = promisify(potentialNewDb.close.bind(potentialNewDb)); // Getting the new user @@ -34,9 +38,27 @@ async function restoreBackup(backupFilePath) { // shutting down the current DB await this.sequelize.close(); // copy the backupFile to the new DB - await exec(`sqlite3 ${this.config.storage} ".restore '${backupFilePath}'"`); + await exec(`sqlite3 ${this.config.storage} ".restore '${sqliteBackupFilePath}'"`); // done! - logger.info(`Backup restored. Need reboot now.`); + logger.info(`SQLite backup restored`); + if (duckDbBackupFolderPath) { + // Closing DuckDB current database + logger.info(`Restoring DuckDB folder ${duckDbBackupFolderPath}`); + await new Promise((resolve) => { + db.duckDb.close(() => resolve()); + }); + // Delete current DuckDB file + const duckDbFilePath = `${this.config.storage.replace('.db', '')}.duckdb`; + await fse.remove(duckDbFilePath); + const duckDb = new duckdb.Database(duckDbFilePath); + const duckDbWriteConnection = duckDb.connect(); + const duckDbWriteConnectionAllAsync = promisify(duckDbWriteConnection.all).bind(duckDbWriteConnection); + await duckDbWriteConnectionAllAsync(`IMPORT DATABASE '${duckDbBackupFolderPath}'`); + logger.info(`DuckDB restored with success`); + await new Promise((resolve) => { + duckDb.close(() => resolve()); + }); + } } module.exports = { diff --git a/server/lib/gateway/gateway.restoreBackupEvent.js b/server/lib/gateway/gateway.restoreBackupEvent.js index 29c08e5b43..5925a13305 100644 --- a/server/lib/gateway/gateway.restoreBackupEvent.js +++ b/server/lib/gateway/gateway.restoreBackupEvent.js @@ -13,8 +13,8 @@ async function restoreBackupEvent(event) { this.restoreErrored = false; this.restoreInProgress = true; logger.info(`Receiving restore backup event. File url = ${event.file_url}`); - const { backupFilePath } = await this.downloadBackup(event.file_url); - await this.restoreBackup(backupFilePath); + const { sqliteBackupFilePath, duckDbBackupFolderPath } = await this.downloadBackup(event.file_url); + await this.restoreBackup(sqliteBackupFilePath, duckDbBackupFolderPath); await this.system.shutdown(); } catch (e) { logger.warn(e); diff --git a/server/lib/index.js b/server/lib/index.js index 1222ba3527..7e36c18c4b 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -40,7 +40,7 @@ const { EVENTS } = require('../utils/constants'); * @param {boolean} [params.disableSchedulerLoading] - If true, disable the loading of the scheduler. * @param {boolean} [params.disableAreaLoading] - If true, disable the loading of the areas. * @param {boolean} [params.disableJobInit] - If true, disable the pruning of background jobs. - * @param {boolean} [params.disableDeviceStateAggregation] - If true, disable the aggregation of device states. + * @param {boolean} [params.disableDuckDbMigration] - If true, disable the DuckDB migration. * @returns {object} Return gladys object. * @example * const gladys = Gladys(); @@ -131,6 +131,9 @@ function Gladys(params = {}) { // Execute DB migrations await db.umzug.up(); + // Execute DuckDB DB migration + await db.duckDbCreateTableIfNotExist(); + await system.init(); // this should be before device.init @@ -150,7 +153,7 @@ function Gladys(params = {}) { await scene.init(); } if (!params.disableDeviceLoading) { - await device.init(!params.disableDeviceStateAggregation); + await device.init(!params.disableDuckDbMigration); } if (!params.disableUserLoading) { await user.init(); diff --git a/server/lib/job/job.get.js b/server/lib/job/job.get.js index 942416cd14..0f4ea8a596 100644 --- a/server/lib/job/job.get.js +++ b/server/lib/job/job.get.js @@ -15,6 +15,7 @@ const DEFAULT_OPTIONS = { * @param {number} [options.skip] - Number of elements to skip. * @param {string} [options.order_by] - Order by. * @param {string} [options.order_dir] - Order dir (asc/desc). + * @param {string} [options.type] - Job type to return. * @returns {Promise} Resolve with array of jobs. * @example * const jobs = await gladys.jobs.get(); @@ -26,10 +27,14 @@ async function get(options) { include: [], offset: optionsWithDefault.skip, order: [[optionsWithDefault.order_by, optionsWithDefault.order_dir]], + where: {}, }; if (optionsWithDefault.take !== undefined) { queryParams.limit = optionsWithDefault.take; } + if (optionsWithDefault.type !== undefined) { + queryParams.where.type = optionsWithDefault.type; + } const jobs = await db.Job.findAll(queryParams); jobs.forEach((job) => { job.data = JSON.parse(job.data); diff --git a/server/models/index.js b/server/models/index.js index 080b7957bf..2573f26e01 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,8 +1,13 @@ const Sequelize = require('sequelize'); +const duckdb = require('duckdb'); const Umzug = require('umzug'); +const Promise = require('bluebird'); +const chunk = require('lodash.chunk'); const path = require('path'); +const util = require('util'); const getConfig = require('../utils/getConfig'); const logger = require('../utils/logger'); +const { formatDateInUTC } = require('../utils/date'); const config = getConfig(); @@ -79,10 +84,64 @@ Object.values(models) .filter((model) => typeof model.associate === 'function') .forEach((model) => model.associate(models)); +// DuckDB +const duckDbFilePath = `${config.storage.replace('.db', '')}.duckdb`; +const duckDb = new duckdb.Database(duckDbFilePath); +const duckDbWriteConnection = duckDb.connect(); +const duckDbReadConnection = duckDb.connect(); +const duckDbWriteConnectionAllAsync = util.promisify(duckDbWriteConnection.all).bind(duckDbWriteConnection); +const duckDbReadConnectionAllAsync = util.promisify(duckDbReadConnection.all).bind(duckDbReadConnection); + +const duckDbCreateTableIfNotExist = async () => { + logger.info(`DuckDB - Creating database table if not exist`); + await duckDbWriteConnectionAllAsync(` + CREATE TABLE IF NOT EXISTS t_device_feature_state ( + device_feature_id UUID, + value DOUBLE, + created_at TIMESTAMPTZ + ); + `); +}; + +const duckDbInsertState = async (deviceFeatureId, value, createdAt) => { + const createdAtInString = formatDateInUTC(createdAt); + await duckDbWriteConnectionAllAsync( + 'INSERT INTO t_device_feature_state VALUES (?, ?, ?)', + deviceFeatureId, + value, + createdAtInString, + ); +}; + +const duckDbBatchInsertState = async (deviceFeatureId, states) => { + const chunks = chunk(states, 10000); + await Promise.each(chunks, async (oneStatesChunk, chunkIndex) => { + let queryString = `INSERT INTO t_device_feature_state (device_feature_id, value, created_at) VALUES `; + const queryParams = []; + oneStatesChunk.forEach((state, index) => { + if (index > 0) { + queryString += `,`; + } + queryString += '(?, ?, ?)'; + queryParams.push(deviceFeatureId); + queryParams.push(state.value); + queryParams.push(formatDateInUTC(state.created_at)); + }); + logger.info(`DuckDB : Inserting chunk ${chunkIndex} for deviceFeature = ${deviceFeatureId}.`); + await duckDbWriteConnectionAllAsync(queryString, ...queryParams); + }); +}; + const db = { ...models, sequelize, umzug, + duckDb, + duckDbWriteConnectionAllAsync, + duckDbReadConnectionAllAsync, + duckDbCreateTableIfNotExist, + duckDbInsertState, + duckDbBatchInsertState, }; module.exports = db; diff --git a/server/package-lock.json b/server/package-lock.json index db08269606..5f6bb4afa6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -21,7 +21,7 @@ "cross-env": "^7.0.3", "dayjs": "^1.11.6", "dockerode": "^3.3.4", - "downsample": "^1.4.0", + "duckdb": "^1.0.0", "express": "^4.18.2", "express-rate-limit": "^6.7.0", "form-data": "^2.3.3", @@ -30,6 +30,7 @@ "handlebars": "^4.7.7", "joi": "^17.7.0", "jsonwebtoken": "^8.5.1", + "lodash.chunk": "^4.2.0", "lodash.clonedeep": "^4.5.0", "lodash.intersection": "^4.4.0", "mathjs": "^11.8.0", @@ -701,8 +702,7 @@ "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "optional": true + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==" }, "node_modules/@gladysassistant/gladys-gateway-js": { "version": "4.14.0", @@ -2180,7 +2180,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.2.1.tgz", "integrity": "sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA==", - "optional": true, "dependencies": { "debug": "^4.1.0", "depd": "^1.1.2", @@ -2194,7 +2193,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "optional": true, "dependencies": { "ms": "2.1.2" }, @@ -2211,7 +2209,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "optional": true, "engines": { "node": ">= 0.6" } @@ -2219,14 +2216,12 @@ "node_modules/agentkeepalive/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "optional": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "devOptional": true, "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -3102,7 +3097,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "devOptional": true, "engines": { "node": ">=6" } @@ -3805,10 +3799,353 @@ "resolved": "https://registry.npmjs.org/doublearray/-/doublearray-0.0.2.tgz", "integrity": "sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==" }, - "node_modules/downsample": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/downsample/-/downsample-1.4.0.tgz", - "integrity": "sha512-teYPhUPxqwtyICt47t1mP/LjhbRV/ghuKb/LmFDbcZ0CjqFD31tn6rVLZoeCEa1xr8+f2skW8UjRiLiGIKQE4w==" + "node_modules/duckdb": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/duckdb/-/duckdb-1.0.0.tgz", + "integrity": "sha512-QwpcIeN42A2lL19S70mUFibZgRcEcZpCkKHdzDgecHaYZhXj3+1i2cxSDyAk/RVg5CYnqj1Dp4jAuN4cc80udA==", + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "node-addon-api": "^7.0.0", + "node-gyp": "^9.3.0" + } + }, + "node_modules/duckdb/node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/duckdb/node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/duckdb/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/duckdb/node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/duckdb/node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/duckdb/node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/duckdb/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/duckdb/node_modules/node-addon-api": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz", + "integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==", + "engines": { + "node": "^16 || ^18 || >= 20" + } + }, + "node_modules/duckdb/node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/duckdb/node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/duckdb/node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/duckdb/node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/duckdb/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/duckdb/node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", @@ -3995,7 +4332,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "optional": true, "engines": { "node": ">=6" } @@ -4015,8 +4351,7 @@ "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "optional": true + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" }, "node_modules/error-ex": { "version": "1.3.2", @@ -4980,6 +5315,11 @@ "node": ">=0.8.x" } }, + "node_modules/exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==" + }, "node_modules/expose-loader": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-4.1.0.tgz", @@ -5813,8 +6153,7 @@ "node_modules/http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", - "optional": true + "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" }, "node_modules/http-errors": { "version": "2.0.0", @@ -5902,7 +6241,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "optional": true, "dependencies": { "ms": "^2.0.0" } @@ -5992,7 +6330,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true, "engines": { "node": ">=0.8.19" } @@ -6001,7 +6338,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "devOptional": true, "engines": { "node": ">=8" } @@ -6009,8 +6345,7 @@ "node_modules/infer-owner": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "optional": true + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==" }, "node_modules/inflection": { "version": "1.13.4", @@ -6065,8 +6400,7 @@ "node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", - "optional": true + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" }, "node_modules/ipaddr.js": { "version": "1.9.1", @@ -6192,8 +6526,7 @@ "node_modules/is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "optional": true + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==" }, "node_modules/is-nan": { "version": "1.3.2", @@ -7123,6 +7456,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.chunk": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.chunk/-/lodash.chunk-4.2.0.tgz", + "integrity": "sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -7607,7 +7945,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -7636,7 +7973,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -7648,7 +7984,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -7660,7 +7995,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -9117,14 +9451,12 @@ "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "optional": true + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==" }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "optional": true, "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -9137,7 +9469,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "optional": true, "engines": { "node": ">= 4" } @@ -10036,7 +10367,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -10114,7 +10444,6 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", - "optional": true, "dependencies": { "ip": "^2.0.0", "smart-buffer": "^4.2.0" @@ -12244,8 +12573,7 @@ "@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "optional": true + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==" }, "@gladysassistant/gladys-gateway-js": { "version": "4.14.0", @@ -13589,7 +13917,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.2.1.tgz", "integrity": "sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA==", - "optional": true, "requires": { "debug": "^4.1.0", "depd": "^1.1.2", @@ -13600,7 +13927,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "optional": true, "requires": { "ms": "2.1.2" } @@ -13608,14 +13934,12 @@ "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "optional": true + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "optional": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" } } }, @@ -13623,7 +13947,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "devOptional": true, "requires": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -14276,8 +14599,7 @@ "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "devOptional": true + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==" }, "cli-color": { "version": "2.0.3", @@ -14816,10 +15138,266 @@ "resolved": "https://registry.npmjs.org/doublearray/-/doublearray-0.0.2.tgz", "integrity": "sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==" }, - "downsample": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/downsample/-/downsample-1.4.0.tgz", - "integrity": "sha512-teYPhUPxqwtyICt47t1mP/LjhbRV/ghuKb/LmFDbcZ0CjqFD31tn6rVLZoeCEa1xr8+f2skW8UjRiLiGIKQE4w==" + "duckdb": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/duckdb/-/duckdb-1.0.0.tgz", + "integrity": "sha512-QwpcIeN42A2lL19S70mUFibZgRcEcZpCkKHdzDgecHaYZhXj3+1i2cxSDyAk/RVg5CYnqj1Dp4jAuN4cc80udA==", + "requires": { + "@mapbox/node-pre-gyp": "^1.0.0", + "node-addon-api": "^7.0.0", + "node-gyp": "^9.3.0" + }, + "dependencies": { + "@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "requires": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + } + }, + "@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "requires": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, + "are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + } + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "requires": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "dependencies": { + "glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + } + } + } + }, + "debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "requires": { + "ms": "2.1.2" + } + }, + "gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "requires": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + } + }, + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==" + }, + "make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "requires": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + } + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "requires": { + "encoding": "^0.1.13", + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node-addon-api": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz", + "integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==" + }, + "node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "requires": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + } + }, + "nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "requires": { + "abbrev": "^1.0.0" + } + }, + "npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "requires": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + } + }, + "p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "requires": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + } + }, + "ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "requires": { + "minipass": "^3.1.1" + } + }, + "unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "requires": { + "unique-slug": "^3.0.0" + } + }, + "unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "requires": { + "imurmurhash": "^0.1.4" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "requires": { + "isexe": "^2.0.0" + } + }, + "wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "requires": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + } + } }, "ecdsa-sig-formatter": { "version": "1.0.11", @@ -14966,8 +15544,7 @@ "env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "optional": true + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==" }, "envinfo": { "version": "7.8.1", @@ -14978,8 +15555,7 @@ "err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "optional": true + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" }, "error-ex": { "version": "1.3.2", @@ -15701,6 +16277,11 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true }, + "exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==" + }, "expose-loader": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-4.1.0.tgz", @@ -16295,8 +16876,7 @@ "http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", - "optional": true + "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" }, "http-errors": { "version": "2.0.0", @@ -16363,7 +16943,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "optional": true, "requires": { "ms": "^2.0.0" } @@ -16417,20 +16996,17 @@ "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" }, "indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "devOptional": true + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" }, "infer-owner": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "optional": true + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==" }, "inflection": { "version": "1.13.4", @@ -16476,8 +17052,7 @@ "ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", - "optional": true + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" }, "ipaddr.js": { "version": "1.9.1", @@ -16564,8 +17139,7 @@ "is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "optional": true + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==" }, "is-nan": { "version": "1.3.2", @@ -17281,6 +17855,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash.chunk": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.chunk/-/lodash.chunk-4.2.0.tgz", + "integrity": "sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==" + }, "lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -17692,7 +18271,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "optional": true, "requires": { "minipass": "^3.0.0" } @@ -17713,7 +18291,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "optional": true, "requires": { "minipass": "^3.0.0" } @@ -17722,7 +18299,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "optional": true, "requires": { "minipass": "^3.0.0" } @@ -17731,7 +18307,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "optional": true, "requires": { "minipass": "^3.0.0" } @@ -18843,14 +19418,12 @@ "promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "optional": true + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==" }, "promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "optional": true, "requires": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -18859,8 +19432,7 @@ "retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "optional": true + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==" } } }, @@ -19526,8 +20098,7 @@ "smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "optional": true + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" }, "socket.io-client": { "version": "4.5.4", @@ -19583,7 +20154,6 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", - "optional": true, "requires": { "ip": "^2.0.0", "smart-buffer": "^4.2.0" diff --git a/server/package.json b/server/package.json index 6a003616c7..c7cca8f9ff 100644 --- a/server/package.json +++ b/server/package.json @@ -86,7 +86,7 @@ "cross-env": "^7.0.3", "dayjs": "^1.11.6", "dockerode": "^3.3.4", - "downsample": "^1.4.0", + "duckdb": "^1.0.0", "express": "^4.18.2", "express-rate-limit": "^6.7.0", "form-data": "^2.3.3", @@ -95,6 +95,7 @@ "handlebars": "^4.7.7", "joi": "^17.7.0", "jsonwebtoken": "^8.5.1", + "lodash.chunk": "^4.2.0", "lodash.clonedeep": "^4.5.0", "lodash.intersection": "^4.4.0", "mathjs": "^11.8.0", diff --git a/server/test/bootstrap.test.js b/server/test/bootstrap.test.js index b610ca29f8..e12f7f5e27 100644 --- a/server/test/bootstrap.test.js +++ b/server/test/bootstrap.test.js @@ -20,7 +20,7 @@ before(async function before() { disableService: true, disableBrainLoading: true, disableSchedulerLoading: true, - disableDeviceStateAggregation: true, + disableDuckDbMigration: true, jwtSecret: 'secret', }; const gladys = Gladys(config); diff --git a/server/test/controllers/device/device.controller.test.js b/server/test/controllers/device/device.controller.test.js index f58facf988..f59e82caec 100644 --- a/server/test/controllers/device/device.controller.test.js +++ b/server/test/controllers/device/device.controller.test.js @@ -1,15 +1,9 @@ const { expect } = require('chai'); -const uuid = require('uuid'); -const EventEmitter = require('events'); -const { fake } = require('sinon'); const db = require('../../../models'); -const Device = require('../../../lib/device'); -const Job = require('../../../lib/job'); const { authenticatedRequest } = require('../request.test'); const insertStates = async (intervalInMinutes) => { - const queryInterface = db.sequelize.getQueryInterface(); const deviceFeatureStateToInsert = []; const now = new Date(); const statesToInsert = 2000; @@ -17,14 +11,11 @@ const insertStates = async (intervalInMinutes) => { const startAt = new Date(now.getTime() - intervalInMinutes * 60 * 1000); const date = new Date(startAt.getTime() + ((intervalInMinutes * 60 * 1000) / statesToInsert) * i); deviceFeatureStateToInsert.push({ - id: uuid.v4(), - device_feature_id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', value: i, created_at: date, - updated_at: date, }); } - await queryInterface.bulkInsert('t_device_feature_state', deviceFeatureStateToInsert); + await db.duckDbBatchInsertState('ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', deviceFeatureStateToInsert); }; describe('POST /api/v1/device', () => { @@ -64,15 +55,6 @@ describe('GET /api/v1/device_feature/aggregated_states', () => { beforeEach(async function BeforeEach() { this.timeout(10000); await insertStates(365 * 24 * 60); - const variable = { - getValue: fake.resolves(null), - }; - const event = new EventEmitter(); - const job = new Job(event); - const device = new Device(event, {}, {}, {}, {}, variable, job); - await device.calculateAggregate('hourly'); - await device.calculateAggregate('daily'); - await device.calculateAggregate('monthly'); }); it('should get device aggregated state by selector', async () => { await authenticatedRequest @@ -137,6 +119,49 @@ describe('GET /api/v1/device', () => { }); }); +describe('GET /api/v1/device/duckdb_migration_state', () => { + it('should get duck db migration state', async () => { + await authenticatedRequest + .get('/api/v1/device/duckdb_migration_state') + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).to.deep.equal({ + duck_db_device_count: 0, + is_duck_db_migrated: false, + sqlite_db_device_state_count: 0, + }); + }); + }); +}); + +describe('POST /api/v1/device/purge_all_sqlite_state', () => { + it('should delete all sqlite states', async () => { + await authenticatedRequest + .post('/api/v1/device/purge_all_sqlite_state') + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).to.deep.equal({ + success: true, + }); + }); + }); +}); +describe('POST /api/v1/device/migrate_from_sqlite_to_duckdb', () => { + it('should migrate to duckdb', async () => { + await authenticatedRequest + .post('/api/v1/device/migrate_from_sqlite_to_duckdb') + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).to.deep.equal({ + success: true, + }); + }); + }); +}); + describe('GET /api/v1/service/:service_name/device', () => { it('should return devices in service test-service', async () => { await authenticatedRequest diff --git a/server/test/helpers/db.test.js b/server/test/helpers/db.test.js index af7da8a243..65f2ee68db 100644 --- a/server/test/helpers/db.test.js +++ b/server/test/helpers/db.test.js @@ -17,10 +17,13 @@ const seedDb = async () => { }; const cleanDb = async () => { + // Clean SQLite database const queryInterface = db.sequelize.getQueryInterface(); await Promise.each(reversedSeed, async (seed) => { await seed.down(queryInterface); }); + // Clean DuckDB database + await db.duckDbWriteConnectionAllAsync('DELETE FROM t_device_feature_state'); }; module.exports = { diff --git a/server/test/lib/device/device.calculateAggregate.test.js b/server/test/lib/device/device.calculateAggregate.test.js deleted file mode 100644 index dfc843a3a9..0000000000 --- a/server/test/lib/device/device.calculateAggregate.test.js +++ /dev/null @@ -1,165 +0,0 @@ -const { expect } = require('chai'); -const uuid = require('uuid'); -const EventEmitter = require('events'); -const sinon = require('sinon'); - -const { fake } = sinon; -const db = require('../../../models'); -const Device = require('../../../lib/device'); -const Job = require('../../../lib/job'); - -const event = new EventEmitter(); - -const insertStates = async (fixedHour = true) => { - const queryInterface = db.sequelize.getQueryInterface(); - const deviceFeatureStateToInsert = []; - const now = new Date(); - for (let i = 1; i <= 30; i += 1) { - for (let j = 0; j < 120; j += 1) { - const date = new Date(now.getFullYear() - 1, 10, i, fixedHour ? 12 : Math.round((j + 1) / 5), 0); - deviceFeatureStateToInsert.push({ - id: uuid.v4(), - device_feature_id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', - value: i, - created_at: date, - updated_at: date, - }); - } - } - await queryInterface.bulkInsert('t_device_feature_state', deviceFeatureStateToInsert); -}; - -describe('Device.calculateAggregate', function Before() { - this.timeout(60000); - let clock; - beforeEach(async () => { - const queryInterface = db.sequelize.getQueryInterface(); - await queryInterface.bulkDelete('t_device_feature_state'); - await queryInterface.bulkDelete('t_device_feature_state_aggregate'); - await db.DeviceFeature.update( - { - last_monthly_aggregate: null, - last_daily_aggregate: null, - last_hourly_aggregate: null, - }, - { where: {} }, - ); - clock = sinon.useFakeTimers({ - now: 1635131280000, - }); - }); - afterEach(() => { - clock.restore(); - }); - it('should calculate hourly aggregate', async () => { - await insertStates(false); - const variable = { - // we modify the retention policy to take the last 1000 days (it'll cover this last year) - getValue: fake.resolves('1000'), - }; - const job = new Job(event); - const device = new Device(event, {}, {}, {}, {}, variable, job); - await device.calculateAggregate('hourly'); - const deviceFeatureStates = await db.DeviceFeatureStateAggregate.findAll({ - raw: true, - attributes: ['value', 'created_at'], - order: [['created_at', 'ASC']], - }); - // Max number of events - expect(deviceFeatureStates.length).to.equal(3600); - }); - it('should calculate hourly aggregate with retention policy instead of last aggregate', async () => { - await insertStates(false); - const deviceFeaturesInDb = await db.DeviceFeature.findAll(); - deviceFeaturesInDb[0].update({ - // old date - last_hourly_aggregate: new Date(2010, 10, 10), - }); - const variable = { - // we modify the retention policy to take the last 5 days only (for testing) - getValue: fake.resolves('5'), - }; - const job = new Job(event); - const device = new Device(event, {}, {}, {}, {}, variable, job); - await device.calculateAggregate('hourly'); - }); - it('should calculate hourly aggregate with last aggregate from device', async () => { - await insertStates(false); - const deviceFeaturesInDb = await db.DeviceFeature.findAll(); - deviceFeaturesInDb[0].update({ - last_hourly_aggregate: new Date(), - }); - const variable = { - // we modify the retention policy to take the last 1000 days (for testing) - getValue: fake.resolves('1000'), - }; - const job = new Job(event); - const device = new Device(event, {}, {}, {}, {}, variable, job); - await device.calculateAggregate('hourly'); - }); - it('should calculate daily aggregate', async () => { - await insertStates(true); - const variable = { - getValue: fake.resolves(null), - }; - const job = new Job(event); - const device = new Device(event, {}, {}, {}, {}, variable, job); - await device.calculateAggregate('daily'); - const deviceFeatureStates = await db.DeviceFeatureStateAggregate.findAll({ - raw: true, - attributes: ['value', 'created_at'], - order: [['created_at', 'ASC']], - }); - // 30 days * 100 = 3000 - expect(deviceFeatureStates.length).to.equal(3000); - }); - it('should calculate monthly aggregate', async () => { - await insertStates(true); - const variable = { - getValue: fake.resolves(null), - }; - const job = new Job(event); - const device = new Device(event, {}, {}, {}, {}, variable, job); - await device.calculateAggregate('monthly'); - const deviceFeatureStates = await db.DeviceFeatureStateAggregate.findAll({ - raw: true, - attributes: ['value', 'created_at'], - order: [['created_at', 'ASC']], - }); - // 1 month * 100 - expect(deviceFeatureStates.length).to.equal(100); - }); - it('should run the hourly aggregate task', async () => { - await insertStates(true); - const variable = { - // we modify the retention policy to take the last 1000 days (it'll cover this last year) - getValue: fake.resolves('1000'), - }; - const job = new Job(event); - const device = new Device(event, {}, {}, {}, {}, variable, job); - await device.onHourlyDeviceAggregateEvent(); - const deviceFeatureStates = await db.DeviceFeatureStateAggregate.findAll({ - raw: true, - attributes: ['value', 'created_at'], - order: [['created_at', 'ASC']], - }); - // daily + hourly + monthly - expect(deviceFeatureStates.length).to.equal(3000 + 3000 + 100); - }); - it('should run the hourly aggregate task with failed job but not crash', async () => { - await insertStates(true); - const variable = { - // we modify the retention policy to take the last 1000 days (it'll cover this last year) - getValue: fake.resolves('1000'), - }; - const job = new Job(event); - job.wrapper = () => { - return async () => { - throw new Error('something failed'); - }; - }; - const device = new Device(event, {}, {}, {}, {}, variable, job); - await device.onHourlyDeviceAggregateEvent(); - // if it doesn't crash, it worked - }); -}); diff --git a/server/test/lib/device/device.destroy.test.js b/server/test/lib/device/device.destroy.test.js index 70b4a91f9e..903ed7c1b4 100644 --- a/server/test/lib/device/device.destroy.test.js +++ b/server/test/lib/device/device.destroy.test.js @@ -1,5 +1,5 @@ const EventEmitter = require('events'); -const { assert } = require('chai'); +const { assert, expect } = require('chai'); const { fake, assert: sinonAssert } = require('sinon'); const Device = require('../../../lib/device'); const StateManager = require('../../../lib/state'); @@ -12,16 +12,22 @@ const event = new EventEmitter(); const job = new Job(event); describe('Device.destroy', () => { - it('should destroy device', async () => { + it('should destroy device and states', async () => { const stateManager = new StateManager(event); const serviceManager = new ServiceManager({}, stateManager); const device = new Device(event, {}, stateManager, serviceManager, {}, {}, job); + await db.duckDbInsertState('ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', 1); device.devicesByPollFrequency[60000] = [ { selector: 'test-device', }, ]; await device.destroy('test-device'); + const res = await db.duckDbReadConnectionAllAsync( + 'SELECT * FROM t_device_feature_state WHERE device_feature_id = ?', + 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', + ); + expect(res).to.have.lengthOf(0); }); it('should destroy device that has too much states', async () => { const eventFake = { diff --git a/server/test/lib/device/device.getDeviceFeaturesAggregates.test.js b/server/test/lib/device/device.getDeviceFeaturesAggregates.test.js index b2daef1e8e..5802351d96 100644 --- a/server/test/lib/device/device.getDeviceFeaturesAggregates.test.js +++ b/server/test/lib/device/device.getDeviceFeaturesAggregates.test.js @@ -1,7 +1,6 @@ const EventEmitter = require('events'); const { expect, assert } = require('chai'); const sinon = require('sinon'); -const uuid = require('uuid'); const { fake } = require('sinon'); const db = require('../../../models'); const Device = require('../../../lib/device'); @@ -11,7 +10,6 @@ const event = new EventEmitter(); const job = new Job(event); const insertStates = async (intervalInMinutes) => { - const queryInterface = db.sequelize.getQueryInterface(); const deviceFeatureStateToInsert = []; const now = new Date(); const statesToInsert = 2000; @@ -19,14 +17,11 @@ const insertStates = async (intervalInMinutes) => { const startAt = new Date(now.getTime() - intervalInMinutes * 60 * 1000); const date = new Date(startAt.getTime() + ((intervalInMinutes * 60 * 1000) / statesToInsert) * i); deviceFeatureStateToInsert.push({ - id: uuid.v4(), - device_feature_id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', value: i, created_at: date, - updated_at: date, }); } - await queryInterface.bulkInsert('t_device_feature_state', deviceFeatureStateToInsert); + await db.duckDbBatchInsertState('ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', deviceFeatureStateToInsert); }; describe('Device.getDeviceFeaturesAggregates', function Describe() { @@ -34,17 +29,7 @@ describe('Device.getDeviceFeaturesAggregates', function Describe() { let clock; beforeEach(async () => { - const queryInterface = db.sequelize.getQueryInterface(); - await queryInterface.bulkDelete('t_device_feature_state'); - await queryInterface.bulkDelete('t_device_feature_state_aggregate'); - await db.DeviceFeature.update( - { - last_monthly_aggregate: null, - last_daily_aggregate: null, - last_hourly_aggregate: null, - }, - { where: {} }, - ); + await db.duckDbWriteConnectionAllAsync('DELETE FROM t_device_feature_state'); clock = sinon.useFakeTimers({ now: 1635131280000, @@ -65,7 +50,6 @@ describe('Device.getDeviceFeaturesAggregates', function Describe() { }), }; const deviceInstance = new Device(event, {}, stateManager, {}, {}, variable, job); - await deviceInstance.calculateAggregate('hourly'); const { values, device, deviceFeature } = await deviceInstance.getDeviceFeaturesAggregates( 'test-device-feature', 60, @@ -87,7 +71,6 @@ describe('Device.getDeviceFeaturesAggregates', function Describe() { }), }; const device = new Device(event, {}, stateManager, {}, {}, variable, job); - await device.calculateAggregate('hourly'); const { values } = await device.getDeviceFeaturesAggregates('test-device-feature', 24 * 60, 100); expect(values).to.have.lengthOf(100); }); @@ -103,9 +86,8 @@ describe('Device.getDeviceFeaturesAggregates', function Describe() { }), }; const device = new Device(event, {}, stateManager, {}, {}, variable, job); - await device.calculateAggregate('hourly'); const { values } = await device.getDeviceFeaturesAggregates('test-device-feature', 3 * 24 * 60, 100); - expect(values).to.have.lengthOf(72); + expect(values).to.have.lengthOf(100); }); it('should return last month states', async () => { await insertStates(2 * 30 * 24 * 60); @@ -119,10 +101,8 @@ describe('Device.getDeviceFeaturesAggregates', function Describe() { }), }; const device = new Device(event, {}, stateManager, {}, {}, variable, job); - await device.calculateAggregate('hourly'); - await device.calculateAggregate('daily'); const { values } = await device.getDeviceFeaturesAggregates('test-device-feature', 30 * 24 * 60, 100); - expect(values).to.have.lengthOf(30); + expect(values).to.have.lengthOf(100); }); it('should return last year states', async () => { await insertStates(2 * 365 * 24 * 60); @@ -136,9 +116,6 @@ describe('Device.getDeviceFeaturesAggregates', function Describe() { }), }; const device = new Device(event, {}, stateManager, {}, {}, variable, job); - await device.calculateAggregate('hourly'); - await device.calculateAggregate('daily'); - await device.calculateAggregate('monthly'); const { values } = await device.getDeviceFeaturesAggregates('test-device-feature', 365 * 24 * 60, 100); expect(values).to.have.lengthOf(100); }); diff --git a/server/test/lib/device/device.getDeviceFeaturesAggregatesMulti.test.js b/server/test/lib/device/device.getDeviceFeaturesAggregatesMulti.test.js index 34227090f2..27788e2b39 100644 --- a/server/test/lib/device/device.getDeviceFeaturesAggregatesMulti.test.js +++ b/server/test/lib/device/device.getDeviceFeaturesAggregatesMulti.test.js @@ -1,6 +1,5 @@ const EventEmitter = require('events'); const { expect } = require('chai'); -const uuid = require('uuid'); const { fake } = require('sinon'); const db = require('../../../models'); const Device = require('../../../lib/device'); @@ -10,7 +9,6 @@ const event = new EventEmitter(); const job = new Job(event); const insertStates = async (intervalInMinutes) => { - const queryInterface = db.sequelize.getQueryInterface(); const deviceFeatureStateToInsert = []; const now = new Date(); const statesToInsert = 2000; @@ -18,14 +16,11 @@ const insertStates = async (intervalInMinutes) => { const startAt = new Date(now.getTime() - intervalInMinutes * 60 * 1000); const date = new Date(startAt.getTime() + ((intervalInMinutes * 60 * 1000) / statesToInsert) * i); deviceFeatureStateToInsert.push({ - id: uuid.v4(), - device_feature_id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', value: i, created_at: date, - updated_at: date, }); } - await queryInterface.bulkInsert('t_device_feature_state', deviceFeatureStateToInsert); + await db.duckDbBatchInsertState('ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', deviceFeatureStateToInsert); }; describe('Device.getDeviceFeaturesAggregatesMulti', function Describe() { @@ -55,7 +50,6 @@ describe('Device.getDeviceFeaturesAggregatesMulti', function Describe() { }), }; const device = new Device(event, {}, stateManager, {}, {}, variable, job); - await device.calculateAggregate('hourly'); const response = await device.getDeviceFeaturesAggregatesMulti(['test-device-feature'], 60, 100); expect(response).to.be.instanceOf(Array); const { values } = response[0]; diff --git a/server/test/lib/device/device.getDuckDbMigrationState.test.js b/server/test/lib/device/device.getDuckDbMigrationState.test.js new file mode 100644 index 0000000000..a8a5eea79b --- /dev/null +++ b/server/test/lib/device/device.getDuckDbMigrationState.test.js @@ -0,0 +1,38 @@ +const EventEmitter = require('events'); +const { expect } = require('chai'); +const { fake } = require('sinon'); +const Device = require('../../../lib/device'); +const StateManager = require('../../../lib/state'); +const Job = require('../../../lib/job'); + +const event = new EventEmitter(); +const job = new Job(event); + +describe('Device.getDuckDbMigrationState', () => { + it('should return migration not done', async () => { + const stateManager = new StateManager(event); + const variable = { + getValue: fake.resolves(null), + }; + const device = new Device(event, {}, stateManager, {}, {}, variable, job); + const migrationState = await device.getDuckDbMigrationState(); + expect(migrationState).to.deep.equal({ + is_duck_db_migrated: false, + duck_db_device_count: 0, + sqlite_db_device_state_count: 0, + }); + }); + it('should return migration done', async () => { + const stateManager = new StateManager(event); + const variable = { + getValue: fake.resolves('true'), + }; + const device = new Device(event, {}, stateManager, {}, {}, variable, job); + const migrationState = await device.getDuckDbMigrationState(); + expect(migrationState).to.deep.equal({ + is_duck_db_migrated: true, + duck_db_device_count: 0, + sqlite_db_device_state_count: 0, + }); + }); +}); diff --git a/server/test/lib/device/device.init.test.js b/server/test/lib/device/device.init.test.js index c06c7b8ff6..5d577196b1 100644 --- a/server/test/lib/device/device.init.test.js +++ b/server/test/lib/device/device.init.test.js @@ -1,7 +1,6 @@ const { assert, fake } = require('sinon'); const Device = require('../../../lib/device'); const StateManager = require('../../../lib/state'); -const { EVENTS } = require('../../../utils/constants'); const Job = require('../../../lib/job'); const event = { @@ -21,8 +20,9 @@ describe('Device.init', () => { const stateManager = new StateManager(event); const job = new Job(event); const device = new Device(event, {}, stateManager, service, {}, {}, job, brain); + device.migrateFromSQLiteToDuckDb = fake.returns(null); - await device.init(true); - assert.calledWith(event.emit, EVENTS.DEVICE.CALCULATE_HOURLY_AGGREGATE); + await device.init(); + assert.called(device.migrateFromSQLiteToDuckDb); }); }); diff --git a/server/test/lib/device/device.migrateFromSQLiteToDuckDb.test.js b/server/test/lib/device/device.migrateFromSQLiteToDuckDb.test.js new file mode 100644 index 0000000000..ca1d1b3366 --- /dev/null +++ b/server/test/lib/device/device.migrateFromSQLiteToDuckDb.test.js @@ -0,0 +1,78 @@ +const { fake } = require('sinon'); +const { expect } = require('chai'); +const uuid = require('uuid'); + +const db = require('../../../models'); +const Device = require('../../../lib/device'); +const StateManager = require('../../../lib/state'); +const Variable = require('../../../lib/variable'); +const { SYSTEM_VARIABLE_NAMES } = require('../../../utils/constants'); + +const event = { + emit: fake.returns(null), + on: fake.returns(null), +}; + +const brain = { + addNamedEntity: fake.returns(null), +}; +const service = { + getService: () => {}, +}; + +const insertStates = async () => { + const queryInterface = db.sequelize.getQueryInterface(); + const deviceFeatureStateToInsert = []; + const now = new Date(); + for (let i = 1; i <= 1000; i += 1) { + const date = new Date(now.getFullYear() - 1, 10, i).toISOString(); + deviceFeatureStateToInsert.push({ + id: uuid.v4(), + device_feature_id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', + value: i, + created_at: date, + updated_at: date, + }); + } + await queryInterface.bulkInsert('t_device_feature_state', deviceFeatureStateToInsert); +}; + +describe('Device.migrateFromSQLiteToDuckDb', () => { + const variable = new Variable(event); + beforeEach(async () => { + await insertStates(); + await variable.destroy(SYSTEM_VARIABLE_NAMES.DUCKDB_MIGRATED); + }); + it('should migrate with success', async () => { + const stateManager = new StateManager(event); + const job = { + updateProgress: fake.resolves(null), + wrapper: (jobType, func) => func, + }; + const device = new Device(event, {}, stateManager, service, {}, variable, job, brain); + await device.migrateFromSQLiteToDuckDb('2997ec9f-7a3e-4083-a183-f8b9b15d5bec', 500); + const res = await db.duckDbReadConnectionAllAsync( + 'SELECT COUNT(*) as nb_states FROM t_device_feature_state WHERE device_feature_id = $1;', + ['ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4'], + ); + expect(res).to.deep.equal([{ nb_states: 1000n }]); + // Second call will not migrate (already migrated) + await device.migrateFromSQLiteToDuckDb('2997ec9f-7a3e-4083-a183-f8b9b15d5bec', 500); + }); + it('should migrate 2 times if the first time was interrupted', async () => { + const stateManager = new StateManager(event); + const job = { + updateProgress: fake.resolves(null), + wrapper: (jobType, func) => func, + }; + const device = new Device(event, {}, stateManager, service, {}, variable, job, brain); + await device.migrateFromSQLiteToDuckDb('2997ec9f-7a3e-4083-a183-f8b9b15d5bec', 500); + await variable.destroy(SYSTEM_VARIABLE_NAMES.DUCKDB_MIGRATED); + await device.migrateFromSQLiteToDuckDb('2997ec9f-7a3e-4083-a183-f8b9b15d5bec', 500); + const res = await db.duckDbReadConnectionAllAsync( + 'SELECT COUNT(*) as nb_states FROM t_device_feature_state WHERE device_feature_id = $1;', + ['ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4'], + ); + expect(res).to.deep.equal([{ nb_states: 1000n }]); + }); +}); diff --git a/server/test/lib/device/device.purgeAggregateStates.test.js b/server/test/lib/device/device.purgeAggregateStates.test.js deleted file mode 100644 index 4fcb2a4d06..0000000000 --- a/server/test/lib/device/device.purgeAggregateStates.test.js +++ /dev/null @@ -1,54 +0,0 @@ -const { expect } = require('chai'); -const EventEmitter = require('events'); -const { fake } = require('sinon'); - -const Device = require('../../../lib/device'); - -const StateManager = require('../../../lib/state'); -const Job = require('../../../lib/job'); - -const event = new EventEmitter(); -const job = new Job(event); - -describe('Device.purgeAggregateStates', () => { - it('should purgeAggregateStates', async () => { - const variable = { - getValue: fake.resolves(30), - }; - const stateManager = new StateManager(event); - const service = {}; - const device = new Device(event, {}, stateManager, service, {}, variable, job); - const devicePurged = await device.purgeAggregateStates(); - expect(devicePurged).to.equal(true); - }); - it('should not purgeAggregateStates, invalid date', async () => { - const variable = { - getValue: fake.resolves('NOT A DATE'), - }; - const stateManager = new StateManager(event); - const service = {}; - const device = new Device(event, {}, stateManager, service, {}, variable, job); - const devicePurged = await device.purgeAggregateStates(); - expect(devicePurged).to.equal(false); - }); - it('should not purgeAggregateStates, null', async () => { - const variable = { - getValue: fake.resolves(null), - }; - const stateManager = new StateManager(event); - const service = {}; - const device = new Device(event, {}, stateManager, service, {}, variable, job); - const devicePurged = await device.purgeAggregateStates(); - expect(devicePurged).to.equal(false); - }); - it('should not purgeAggregateStates, date = -1', async () => { - const variable = { - getValue: fake.resolves('-1'), - }; - const stateManager = new StateManager(event); - const service = {}; - const device = new Device(event, {}, stateManager, service, {}, variable, job); - const devicePurged = await device.purgeAggregateStates(); - expect(devicePurged).to.equal(false); - }); -}); diff --git a/server/test/lib/device/device.purgeAllSqliteStates.test.js b/server/test/lib/device/device.purgeAllSqliteStates.test.js new file mode 100644 index 0000000000..d9cac1ce47 --- /dev/null +++ b/server/test/lib/device/device.purgeAllSqliteStates.test.js @@ -0,0 +1,65 @@ +const { expect } = require('chai'); +const EventEmitter = require('events'); +const { fake } = require('sinon'); +const uuid = require('uuid'); + +const db = require('../../../models'); +const Device = require('../../../lib/device'); + +const StateManager = require('../../../lib/state'); + +const event = new EventEmitter(); + +describe('Device', () => { + beforeEach(async () => { + const queryInterface = db.sequelize.getQueryInterface(); + const deviceFeatureStateToInsert = []; + for (let i = 1; i <= 110; i += 1) { + const date = new Date(); + deviceFeatureStateToInsert.push({ + id: uuid.v4(), + device_feature_id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', + value: i, + created_at: date, + updated_at: date, + }); + } + await queryInterface.bulkInsert('t_device_feature_state', deviceFeatureStateToInsert); + await db.DeviceFeatureStateAggregate.create({ + type: 'daily', + device_feature_id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', + value: 12, + }); + await db.DeviceFeatureStateAggregate.create({ + type: 'hourly', + device_feature_id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', + value: 12, + }); + await db.DeviceFeatureStateAggregate.create({ + type: 'monthly', + device_feature_id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', + value: 12, + }); + }); + it('should purge all sqlite states', async () => { + const variable = { + getValue: fake.resolves(30), + }; + const stateManager = new StateManager(event); + const service = {}; + const job = { + updateProgress: fake.resolves(null), + wrapper: (type, func) => func, + }; + const device = new Device(event, {}, stateManager, service, {}, variable, job); + const devicePurgedPromise = device.purgeAllSqliteStates('632c6d92-a79a-4a38-bf5b-a2024721c101'); + const emptyRes = await device.purgeAllSqliteStates('632c6d92-a79a-4a38-bf5b-a2024721c101'); + const devicePurged = await devicePurgedPromise; + expect(devicePurged).to.deep.equal({ + numberOfDeviceFeatureStateAggregateToDelete: 3, + numberOfDeviceFeatureStateToDelete: 110, + }); + // should not start a new purge when a purge is running + expect(emptyRes).to.equal(null); + }); +}); diff --git a/server/test/lib/device/device.purgeStates.test.js b/server/test/lib/device/device.purgeStates.test.js index 3f300c7f92..e97c512d83 100644 --- a/server/test/lib/device/device.purgeStates.test.js +++ b/server/test/lib/device/device.purgeStates.test.js @@ -1,6 +1,7 @@ const { expect } = require('chai'); const EventEmitter = require('events'); const { fake } = require('sinon'); +const db = require('../../../models'); const Device = require('../../../lib/device'); @@ -15,11 +16,29 @@ describe('Device', () => { const variable = { getValue: fake.resolves(30), }; + const currentDate = new Date(); + const fortyDaysAgo = new Date(currentDate); + fortyDaysAgo.setDate(currentDate.getDate() - 40); + await db.duckDbBatchInsertState('ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', [ + { + value: 1, + created_at: fortyDaysAgo, + }, + { + value: 10, + created_at: currentDate, + }, + ]); const stateManager = new StateManager(event); const service = {}; const device = new Device(event, {}, stateManager, service, {}, variable, job); const devicePurged = await device.purgeStates(); expect(devicePurged).to.equal(true); + const res = await db.duckDbReadConnectionAllAsync( + 'SELECT value FROM t_device_feature_state WHERE device_feature_id = ?', + 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', + ); + expect(res).to.deep.equal([{ value: 10 }]); }); it('should not purgeStates, invalid date', async () => { const variable = { diff --git a/server/test/lib/device/device.saveHistoricalState.test.js b/server/test/lib/device/device.saveHistoricalState.test.js index 9507beed19..7c89a393f3 100644 --- a/server/test/lib/device/device.saveHistoricalState.test.js +++ b/server/test/lib/device/device.saveHistoricalState.test.js @@ -67,12 +67,12 @@ describe('Device.saveHistoricalState', () => { // Should not save 2 times the same event await device.saveHistoricalState(deviceFeature, 12, newDate); await device.saveHistoricalState(deviceFeature, 12, newDate); - const deviceStates = await db.DeviceFeatureState.findAll({ - where: { - device_feature_id: 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', - }, - raw: true, - }); + const deviceStates = await db.duckDbReadConnectionAllAsync( + ` + SELECT * FROM t_device_feature_state WHERE device_feature_id = ? + `, + 'ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', + ); expect(deviceStates).to.have.lengthOf(1); }); it('should save old state and keep history, and update aggregate', async () => { diff --git a/server/test/lib/gateway/encoded-gladys-db-and-duckdb-backup.tar.gz.enc b/server/test/lib/gateway/encoded-gladys-db-and-duckdb-backup.tar.gz.enc new file mode 100644 index 0000000000000000000000000000000000000000..ee1931130a9ab448d03f17ef7be88a2b1ec5bc6e GIT binary patch literal 15104 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
    k?~R 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 literal 0 HcmV?d00001 diff --git a/server/test/lib/gateway/encoded-gladys-db-backup.db.gz.enc b/server/test/lib/gateway/encoded-old-gladys-db-backup.db.gz.enc similarity index 100% rename from server/test/lib/gateway/encoded-gladys-db-backup.db.gz.enc rename to server/test/lib/gateway/encoded-old-gladys-db-backup.db.gz.enc diff --git a/server/test/lib/gateway/gateway.backup.test.js b/server/test/lib/gateway/gateway.backup.test.js index a3f2aeb767..257efe66e7 100644 --- a/server/test/lib/gateway/gateway.backup.test.js +++ b/server/test/lib/gateway/gateway.backup.test.js @@ -33,7 +33,7 @@ describe('gateway.backup', async function describe() { updateProgress: fake.resolves({}), }; - variable.getValue = fake.resolves('variable'); + variable.getValue = fake.resolves('key'); variable.setValue = fake.resolves(null); event.on = fake.returns(null); @@ -125,6 +125,7 @@ describe('gateway.backup', async function describe() { value: 1, }), ); + promisesDevices.push(db.duckDbInsertState('ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', 1)); } // start backup promises.push(gateway.backup()); @@ -136,6 +137,7 @@ describe('gateway.backup', async function describe() { value: 1, }), ); + promisesDevices.push(db.duckDbInsertState('ca91dfdf-55b2-4cf8-a58b-99c0fbf6f5e4', 1)); } await Promise.all(promisesDevices); await Promise.all(promises); diff --git a/server/test/lib/gateway/gateway.downloadBackup.test.js b/server/test/lib/gateway/gateway.downloadBackup.test.js index 0aff79f922..23901d3838 100644 --- a/server/test/lib/gateway/gateway.downloadBackup.test.js +++ b/server/test/lib/gateway/gateway.downloadBackup.test.js @@ -71,13 +71,26 @@ describe('gateway.downloadBackup', () => { assert.notCalled(event.emit); }); - it('should download a backup', async () => { - const encryptedBackupFilePath = path.join(__dirname, 'encoded-gladys-db-backup.db.gz.enc'); - const { backupFilePath } = await gateway.downloadBackup(encryptedBackupFilePath); + it('should download a backup (new style, sqlite + parquet)', async () => { + const encryptedBackupFilePath = path.join(__dirname, 'encoded-gladys-db-and-duckdb-backup.tar.gz.enc'); + await gateway.downloadBackup(encryptedBackupFilePath); assert.calledOnceWithExactly(event.emit, EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.BACKUP.DOWNLOADED, payload: { - backupFilePath, + duckDbBackupFolderPath: 'gladys-backups/restore/gladys-db-backup_2024-6-29-13-47-50_parquet_folder', + sqliteBackupFilePath: 'gladys-backups/restore/gladys-db-backup-2024-6-29-13-47-50.db', + }, + }); + }); + + it('should download a backup (old style, sqlite)', async () => { + const encryptedBackupFilePath = path.join(__dirname, 'encoded-old-gladys-db-backup.db.gz.enc'); + await gateway.downloadBackup(encryptedBackupFilePath); + assert.calledOnceWithExactly(event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.BACKUP.DOWNLOADED, + payload: { + duckDbBackupFolderPath: null, + sqliteBackupFilePath: 'gladys-backups/restore/encoded-old-gladys-db-backup.db.gz.db', }, }); }); diff --git a/server/test/lib/gateway/gateway.restoreBackup.test.js b/server/test/lib/gateway/gateway.restoreBackup.test.js index fe20caf765..1b7e4fdb9c 100644 --- a/server/test/lib/gateway/gateway.restoreBackup.test.js +++ b/server/test/lib/gateway/gateway.restoreBackup.test.js @@ -57,7 +57,14 @@ describe('gateway.restoreBackup', () => { sinon.reset(); }); - it('should restore a backup', async () => { + it('should restore a new style backup (sqlite + duckDB)', async () => { + const backupFilePath = path.join(__dirname, 'real-gladys-db-backup.db.gz.dbfile'); + const parquetFolderPath = path.join(__dirname, 'gladys_backup_parquet_folder'); + await gateway.restoreBackup(backupFilePath, parquetFolderPath); + assert.calledOnceWithExactly(sequelize.close); + }); + + it('should restore a old style backup (just sqlite)', async () => { const backupFilePath = path.join(__dirname, 'real-gladys-db-backup.db.gz.dbfile'); await gateway.restoreBackup(backupFilePath); assert.calledOnceWithExactly(sequelize.close); diff --git a/server/test/lib/gateway/gateway.restoreBackupEvent.test.js b/server/test/lib/gateway/gateway.restoreBackupEvent.test.js index 293ac22fdd..bc36b1eb12 100644 --- a/server/test/lib/gateway/gateway.restoreBackupEvent.test.js +++ b/server/test/lib/gateway/gateway.restoreBackupEvent.test.js @@ -4,7 +4,8 @@ const proxyquire = require('proxyquire').noCallThru(); const path = require('path'); const GladysGatewayClientMock = require('./GladysGatewayClientMock.test'); - +const db = require('../../../models'); +const { cleanDb } = require('../../helpers/db.test'); const getConfig = require('../../../utils/getConfig'); const { fake, assert } = sinon; @@ -55,12 +56,28 @@ describe('gateway.restoreBackupEvent', () => { gateway = new Gateway(variable, event, system, sequelize, config, {}, {}, {}, job, scheduler); }); - afterEach(() => { + afterEach(async () => { sinon.reset(); + await db.umzug.up(); + await cleanDb(); + }); + + it('should download and restore new backup (sqlite + parquet), then shutdown', async () => { + const encryptedBackupFilePath = path.join(__dirname, 'encoded-gladys-db-and-duckdb-backup.tar.gz.enc'); + const restoreBackupEvent = { + file_url: encryptedBackupFilePath, + }; + + await gateway.restoreBackupEvent(restoreBackupEvent); + + expect(gateway.restoreErrored).equals(false); + expect(gateway.restoreInProgress).equals(true); + + assert.calledOnceWithExactly(system.shutdown); }); - it('should download and restore backup, then shutdown', async () => { - const encryptedBackupFilePath = path.join(__dirname, 'encoded-gladys-db-backup.db.gz.enc'); + it('should download and restore old sqlite backup, then shutdown', async () => { + const encryptedBackupFilePath = path.join(__dirname, 'encoded-old-gladys-db-backup.db.gz.enc'); const restoreBackupEvent = { file_url: encryptedBackupFilePath, }; diff --git a/server/test/lib/gateway/gladys_backup_parquet_folder/load.sql b/server/test/lib/gateway/gladys_backup_parquet_folder/load.sql new file mode 100644 index 0000000000..d4883c0840 --- /dev/null +++ b/server/test/lib/gateway/gladys_backup_parquet_folder/load.sql @@ -0,0 +1 @@ +COPY t_device_feature_state FROM 'gladys-backups/gladys-db-backup_2024-6-28-14-57-29_parquet_folder/t_device_feature_state.parquet' (FORMAT 'parquet', COMPRESSION 'GZIP'); diff --git a/server/test/lib/gateway/gladys_backup_parquet_folder/schema.sql b/server/test/lib/gateway/gladys_backup_parquet_folder/schema.sql new file mode 100644 index 0000000000..dd5284ec2e --- /dev/null +++ b/server/test/lib/gateway/gladys_backup_parquet_folder/schema.sql @@ -0,0 +1,8 @@ + + + +CREATE TABLE t_device_feature_state(device_feature_id UUID, "value" DOUBLE, created_at TIMESTAMP WITH TIME ZONE); + + + + diff --git a/server/test/lib/gateway/gladys_backup_parquet_folder/t_device_feature_state.parquet b/server/test/lib/gateway/gladys_backup_parquet_folder/t_device_feature_state.parquet new file mode 100644 index 0000000000000000000000000000000000000000..83eb743619bda936bb772790d02687ac06cbbee6 GIT binary patch literal 119 zcmWG=3^EjDlJqfUkl;-zP0mh9iZ4#iNX< { describe('job.create', () => { const job = new Job(event); it('should create a job', async () => { - const newJob = await job.start(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE); - expect(newJob).to.have.property('type', JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE); + const newJob = await job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP); + expect(newJob).to.have.property('type', JOB_TYPES.GLADYS_GATEWAY_BACKUP); expect(newJob).to.have.property('status', JOB_STATUS.IN_PROGRESS); assert.calledWith(event.emit, EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.JOB.NEW, @@ -31,14 +31,14 @@ describe('Job', () => { return chaiAssert.isRejected(promise, 'Validation error: Validation isIn on type failed'); }); it('should not create a job, invalid data', async () => { - const promise = job.start(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE, []); + const promise = job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP, []); return chaiAssert.isRejected(promise, 'Validation error: "value" must be of type object'); }); }); describe('job.finish', () => { const job = new Job(event); it('should finish a job', async () => { - const newJob = await job.start(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE); + const newJob = await job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP); const updatedJob = await job.finish(newJob.id, JOB_STATUS.SUCCESS, {}); assert.calledWith(event.emit, EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.JOB.UPDATED, @@ -53,7 +53,7 @@ describe('Job', () => { describe('job.updateProgress', () => { const job = new Job(event); it('should update the progress a job', async () => { - const newJob = await job.start(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE); + const newJob = await job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP); const updatedJob = await job.updateProgress(newJob.id, 50); assert.calledWith(event.emit, EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.JOB.UPDATED, @@ -61,12 +61,12 @@ describe('Job', () => { }); }); it('should not update the progress a job, invalid progress', async () => { - const newJob = await job.start(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE); + const newJob = await job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP); const promise = job.updateProgress(newJob.id, 101); return chaiAssert.isRejected(promise, 'Validation error: Validation max on progress failed'); }); it('should not update the progress a job, invalid progress', async () => { - const newJob = await job.start(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE); + const newJob = await job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP); const promise = job.updateProgress(newJob.id, -1); return chaiAssert.isRejected(promise, 'Validation error: Validation min on progress failed'); }); @@ -74,15 +74,24 @@ describe('Job', () => { describe('job.get', () => { const job = new Job(event); it('should get all job', async () => { - await job.start(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE); + await job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP); const jobs = await job.get(); expect(jobs).to.be.instanceOf(Array); jobs.forEach((oneJob) => { expect(oneJob).to.have.property('type'); }); }); + it('should get gateway backup job only', async () => { + await job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP); + await job.start(JOB_TYPES.VACUUM); + const jobs = await job.get({ type: JOB_TYPES.GLADYS_GATEWAY_BACKUP }); + expect(jobs).to.be.instanceOf(Array); + jobs.forEach((oneJob) => { + expect(oneJob).to.have.property('type', JOB_TYPES.GLADYS_GATEWAY_BACKUP); + }); + }); it('should get 0 job', async () => { - await job.start(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE); + await job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP); const jobs = await job.get({ take: 0, }); @@ -93,7 +102,7 @@ describe('Job', () => { describe('job.init', () => { const job = new Job(event); it('should init jobs and mark unfinished jobs as failed', async () => { - const jobCreated = await job.start(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE); + const jobCreated = await job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP); await job.init(); const jobs = await job.get(); expect(jobs).to.be.instanceOf(Array); @@ -102,7 +111,7 @@ describe('Job', () => { .map((oneJob) => ({ type: oneJob.type, status: oneJob.status, data: oneJob.data })); expect(jobsFiltered).to.deep.equal([ { - type: 'daily-device-state-aggregate', + type: 'gladys-gateway-backup', status: 'failed', data: { error_type: 'purged-when-restarted' }, }, @@ -112,7 +121,7 @@ describe('Job', () => { describe('job.purge', () => { const job = new Job(event); it('should purge old jobs', async () => { - await job.start(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE); + await job.start(JOB_TYPES.GLADYS_GATEWAY_BACKUP); const dateInThePast = new Date(new Date().getTime() - 10 * 24 * 60 * 60 * 1000); await db.Job.update({ created_at: dateInThePast }, { where: {} }); await job.purge(); @@ -123,7 +132,7 @@ describe('Job', () => { describe('job.wrapper', () => { const job = new Job(event); it('should test wrapper', async () => { - const wrapped = job.wrapper(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE, () => {}); + const wrapped = job.wrapper(JOB_TYPES.GLADYS_GATEWAY_BACKUP, () => {}); await wrapped(); const jobs = await job.get(); expect(jobs).to.be.instanceOf(Array); @@ -131,7 +140,7 @@ describe('Job', () => { expect(lastJob).to.have.property('status', JOB_STATUS.SUCCESS); }); it('should test wrapper with failed job', async () => { - const wrapped = job.wrapper(JOB_TYPES.DAILY_DEVICE_STATE_AGGREGATE, () => { + const wrapped = job.wrapper(JOB_TYPES.GLADYS_GATEWAY_BACKUP, () => { throw new Error('failed'); }); try { diff --git a/server/utils/constants.js b/server/utils/constants.js index 7312489248..95eae8e42a 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -131,6 +131,7 @@ const SYSTEM_VARIABLE_NAMES = { TIMEZONE: 'TIMEZONE', DEVICE_BATTERY_LEVEL_WARNING_THRESHOLD: 'DEVICE_BATTERY_LEVEL_WARNING_THRESHOLD', DEVICE_BATTERY_LEVEL_WARNING_ENABLED: 'DEVICE_BATTERY_LEVEL_WARNING_ENABLED', + DUCKDB_MIGRATED: 'DUCKDB_MIGRATED', }; const EVENTS = { @@ -158,6 +159,8 @@ const EVENTS = { CALCULATE_HOURLY_AGGREGATE: 'device.calculate-hourly-aggregate', PURGE_STATES_SINGLE_FEATURE: 'device.purge-states-single-feature', CHECK_BATTERIES: 'device.check-batteries', + MIGRATE_FROM_SQLITE_TO_DUCKDB: 'device.migrate-from-sqlite-to-duckdb', + PURGE_ALL_SQLITE_STATES: 'device.purge-all-sqlite-states', }, GATEWAY: { CREATE_BACKUP: 'gateway.create-backup', @@ -1064,9 +1067,11 @@ const JOB_TYPES = { GLADYS_GATEWAY_BACKUP: 'gladys-gateway-backup', DEVICE_STATES_PURGE_SINGLE_FEATURE: 'device-state-purge-single-feature', DEVICE_STATES_PURGE: 'device-state-purge', + DEVICE_STATES_PURGE_ALL_SQLITE_STATES: 'device-state-purge-all-sqlite-states', VACUUM: 'vacuum', SERVICE_ZIGBEE2MQTT_BACKUP: 'service-zigbee2mqtt-backup', SERVICE_NODE_RED_BACKUP: 'service-node-red-backup', + MIGRATE_SQLITE_TO_DUCKDB: 'migrate-sqlite-to-duckdb', }; const JOB_STATUS = { diff --git a/server/utils/date.js b/server/utils/date.js new file mode 100644 index 0000000000..b87b6653e3 --- /dev/null +++ b/server/utils/date.js @@ -0,0 +1,28 @@ +const dayjs = require('dayjs'); +const utc = require('dayjs/plugin/utc'); +const timezone = require('dayjs/plugin/timezone'); + +// Extend dayjs with the necessary plugins +dayjs.extend(utc); +dayjs.extend(timezone); + +/** + * @description Format a date in UTC string. + * @param {Date} date - Javascript date. + * @returns {string} - Return a formatted string, utc time. + * @example const dateStringUtc = formatDateInUTC(new Date()); + */ +function formatDateInUTC(date) { + // Convert date to UTC + const dateInUTC = dayjs(date).utc(); + + // Format the date + const formattedDate = dateInUTC.format('YYYY-MM-DD HH:mm:ss.SSSSSS'); + + // Append the UTC offset + return `${formattedDate}+00`; +} + +module.exports = { + formatDateInUTC, +};