diff --git a/Dockerfile b/Dockerfile index 8075aeee2..ce2e4aec2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,7 +50,7 @@ WORKDIR /app/vikunja ENTRYPOINT [ "/app/vikunja/vikunja" ] EXPOSE 3456 -COPY --from=apibuilder --chown=1000:1000 /tmp /tmp +COPY --from=apibuilder --chown=1000:1000 --chmod=1777 /tmp /tmp USER 1000 diff --git a/build/after-install-openrc.sh b/build/after-install-openrc.sh index c0e01cd03..8c3e9a638 100755 --- a/build/after-install-openrc.sh +++ b/build/after-install-openrc.sh @@ -2,7 +2,7 @@ rc-update add vikunja default # Fix the config to contain proper values -NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) +NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32) sed -i "s//$NEW_SECRET/g" /etc/vikunja/config.yml sed -i "s//\/opt\/vikunja\//g" /etc/vikunja/config.yml sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml diff --git a/build/after-install.sh b/build/after-install.sh index a8f2840d7..1b6399a49 100644 --- a/build/after-install.sh +++ b/build/after-install.sh @@ -3,7 +3,7 @@ systemctl enable vikunja.service # Fix the config to contain proper values -NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) +NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32) sed -i "s//$NEW_SECRET/g" /etc/vikunja/config.yml sed -i "s//\/opt\/vikunja\//g" /etc/vikunja/config.yml sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml diff --git a/desktop/icon.png b/desktop/icon.png new file mode 100644 index 000000000..21b9fc4a6 Binary files /dev/null and b/desktop/icon.png differ diff --git a/desktop/main.js b/desktop/main.js index f670f8fe9..ca84f4c1f 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -397,7 +397,11 @@ function toggleQuickEntry() { // ─── System tray ───────────────────────────────────────────────────── function setupTray() { if (!tray) { - const iconPath = path.join(__dirname, 'build', 'icon.png') + // NOTE: load the icon from the app root, not build/. The build/ directory is + // electron-builder's buildResources dir and is NOT packaged into the app, so + // referencing build/icon.png here works in dev but yields an empty tray icon + // in packaged releases (see issue #2668). + const iconPath = path.join(__dirname, 'icon.png') const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16}) tray = new Tray(icon) tray.setToolTip('Vikunja') diff --git a/frontend/package.json b/frontend/package.json index bd2bac561..4a2b7ac37 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -155,7 +155,7 @@ "vite-plugin-vue-devtools": "8.1.2", "vite-svg-loader": "5.1.1", "vitest": "4.1.7", - "vue-tsc": "3.3.2", + "vue-tsc": "3.3.3", "wait-on": "9.0.10", "workbox-cli": "7.4.1", "ws": "8.21.0" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 518753a69..66f30f6e9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -326,8 +326,8 @@ importers: specifier: 4.1.7 version: 4.1.7(@types/node@24.12.4)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@7.3.3(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) vue-tsc: - specifier: 3.3.2 - version: 3.3.2(typescript@5.9.3) + specifier: 3.3.3 + version: 3.3.3(typescript@5.9.3) wait-on: specifier: 9.0.10 version: 9.0.10 @@ -2139,42 +2139,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -2334,79 +2328,66 @@ packages: resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.4': resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.4': resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.4': resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.4': resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.4': resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.4': resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.4': resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.4': resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.4': resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.4': resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.4': resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.4': resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.4': resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} @@ -2609,28 +2590,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -3211,8 +3188,8 @@ packages: typescript: optional: true - '@vue/language-core@3.3.2': - resolution: {integrity: sha512-CLwjSfHlPLhjd2qhuS3tTFtnOIWHXAM5u4X1DxmzlQ8j5bmOYlKCsSusOP7jCRJnlVg0mCTQtHU3vwFvopZGoQ==} + '@vue/language-core@3.3.3': + resolution: {integrity: sha512-X6p+7nfY7vVT6dQwUJ+v0Jfq/lwIfhL2jMi91dQ3ln4hnlGXlxsDu/FNkeyHYgvYtyQy18ZX76IZy7X4diDbiQ==} '@vue/reactivity@3.5.27': resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==} @@ -4984,28 +4961,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -6010,56 +5983,48 @@ packages: engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - libc: glibc sass-embedded-linux-arm@1.100.0: resolution: {integrity: sha512-9Ul7O1eKrc5YlhwWjkp8tZPSe3UEwSZ1uwUZOQom1HL0pRlBA6F/IlGZYFTLwnHMIP1fc77MMNaBRfc05mKMpw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - libc: glibc sass-embedded-linux-musl-arm64@1.100.0: resolution: {integrity: sha512-XpACJB2KjSLjf2e9uuvGVdOURsoNrFqgRiihhXyUHK9W0t3LIHb7z5MA/7XGPIT9bWSOO2zyw+rH/FHtDV/Yrg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - libc: musl sass-embedded-linux-musl-arm@1.100.0: resolution: {integrity: sha512-sl0JgbGloPyJg66XXx5UDSDScZ0oU85DpMQU4JU/sCUCFj1Z8zZ69SJWKTCNE4/jwnce7WI2zPCV5AG+RHOZJw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - libc: musl sass-embedded-linux-musl-riscv64@1.100.0: resolution: {integrity: sha512-ShvI0Kx04mwoCARwZ0UjiT97isQvzO80tAt91zmFyHLN9kelc/IrQi940farSm2xQVPCKdeVyeG0ekBsokSpYQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - libc: musl sass-embedded-linux-musl-x64@1.100.0: resolution: {integrity: sha512-TDBCRWNuS4RDLQXvRc1gjZlWiWTWaWGp0Bwu/IKwJxov81lsvrCs3TihTyNXtW7V5aoN4Ky3r0QOkNb3mwmBnA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - libc: musl sass-embedded-linux-riscv64@1.100.0: resolution: {integrity: sha512-j4ENJGOheO+fm3j/yorLxCjBP6/XskrZx7dTLlT+lXYwN/qqCqoA/gsNLI0McS3DFM6GBwPiffzWsdWS8t6sEQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - libc: glibc sass-embedded-linux-x64@1.100.0: resolution: {integrity: sha512-0vUSN8j0WGtCJIOPh//EmUvYGHW0QOe5iul8qyhPk50MAcw49MA0r34AhftjDdx94ILPF6vApFs0gwHPQRlpVA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - libc: glibc sass-embedded-unknown-all@1.100.0: resolution: {integrity: sha512-c+naBgWId4MIpToXcI0DgqetjdAkwTTAxFAuOaBz7HUXLdyG1oZRrEvSsbe41nEdQOKH0vgofVFCeSQgoXOG9A==} @@ -6948,8 +6913,8 @@ packages: peerDependencies: vue: ^3.5.0 - vue-tsc@3.3.2: - resolution: {integrity: sha512-n7nQoA3YWW/eiDR8jMiv/uJvlg0uLGs+YgUrsTrf9EZaYSt3tuvMZb5V8+7Mvh/EH5pnY/hoVdgfjH+XcK+wwA==} + vue-tsc@3.3.3: + resolution: {integrity: sha512-SWUEG7YRUeDJHT7Xsuhf02elYX2gxPzzAII7OxDAh4KNOr4QHQ0Lls0YfnaO5GNd560CwVa2HTfdqmA5MqvRqQ==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -10172,7 +10137,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vue/language-core@3.3.2': + '@vue/language-core@3.3.3': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.27 @@ -14277,10 +14242,10 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.27(typescript@5.9.3) - vue-tsc@3.3.2(typescript@5.9.3): + vue-tsc@3.3.3(typescript@5.9.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.3.2 + '@vue/language-core': 3.3.3 typescript: 5.9.3 vue@3.5.27(typescript@5.9.3): diff --git a/pkg/files/files.go b/pkg/files/files.go index a103a5237..a8d702913 100644 --- a/pkg/files/files.go +++ b/pkg/files/files.go @@ -28,8 +28,6 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/log" - "code.vikunja.io/api/pkg/metrics" - "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/web" "github.com/c2h5oh/datasize" @@ -205,7 +203,7 @@ func (f *File) Delete(s *xorm.Session) (err error) { return err } - return keyvalue.DecrBy(metrics.FilesCountKey, 1) + return nil } // Save saves a file to storage @@ -214,5 +212,5 @@ func (f *File) Save(fcontent io.ReadSeeker) error { if err != nil { return fmt.Errorf("failed to save file: %w", err) } - return keyvalue.IncrBy(metrics.FilesCountKey, 1) + return nil } diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index df11ef01f..dca17cb60 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -145,7 +145,6 @@ func FullInit() { // Start processing events go func() { models.RegisterListeners() - user.RegisterListeners() migrationHandler.RegisterListeners() ws.RegisterListeners() err := events.InitEvents() diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 00dce43eb..2ff7c1c6d 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -17,8 +17,9 @@ package metrics import ( - "strconv" + "time" + "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/modules/keyvalue" @@ -36,6 +37,22 @@ const ( AttachmentsCountKey = `attachments_count` ) +// countCacheTTL is how long a cached entity count is served before it is recomputed +// from the database. The counts are inherently approximate (Prometheus samples them), +// so a short staleness window is fine and keeps the cache self-healing — a missed +// InvalidateCount call costs at most this much staleness, never a permanent drift. +const countCacheTTL = 30 * time.Second + +// countTables maps each count metric key to the database table it counts. +var countTables = map[string]string{ + ProjectCountKey: "projects", + UserCountKey: "users", + TaskCountKey: "tasks", + TeamCountKey: "teams", + FilesCountKey: "files", + AttachmentsCountKey: "task_attachments", +} + var registry *prometheus.Registry func GetRegistry() *prometheus.Registry { @@ -53,7 +70,10 @@ func registerPromMetric(key, description string) { Name: "vikunja_" + key, Help: description, }, func() float64 { - count, _ := GetCount(key) + count, err := GetCount(key) + if err != nil { + log.Errorf("Could not get count for metric %s: %s", key, err) + } return float64(count) })) if err != nil { @@ -65,8 +85,8 @@ func registerPromMetric(key, description string) { func InitMetrics() { GetRegistry() - registerPromMetric(ProjectCountKey, "The number of projects on this instance") - registerPromMetric(UserCountKey, "The total number of shares on this instance") + registerPromMetric(ProjectCountKey, "The total number of projects on this instance") + registerPromMetric(UserCountKey, "The total number of users on this instance") registerPromMetric(TaskCountKey, "The total number of tasks on this instance") registerPromMetric(TeamCountKey, "The total number of teams on this instance") registerPromMetric(FilesCountKey, "The total number of files on this instance") @@ -76,26 +96,31 @@ func InitMetrics() { setupActiveLinkSharesMetric() } -// GetCount returns the current count from keyvalue -func GetCount(key string) (count int64, err error) { - cnt, exists, err := keyvalue.Get(key) - if err != nil { - return 0, err - } - if !exists { +// GetCount returns the current count for the given metric key. The value is counted +// directly from the database and cached for countCacheTTL, so repeated scrapes don't +// hit the database on every request. +func GetCount(key string) (int64, error) { + return keyvalue.RememberFor(key, countCacheTTL, func() (int64, error) { + return countFromDatabase(key) + }) +} + +// countFromDatabase runs a COUNT(*) for the table backing the given metric key. +func countFromDatabase(key string) (int64, error) { + table, has := countTables[key] + if !has { return 0, nil } - if s, is := cnt.(string); is { - count, err = strconv.ParseInt(s, 10, 64) - } else { - count = cnt.(int64) - } + s := db.NewSession() + defer s.Close() - return + return s.Table(table).Count() } -// SetCount sets the project count to a given value -func SetCount(count int64, key string) error { - return keyvalue.Put(key, count) +// InvalidateCount drops the cached count for a key so the next read recomputes it from +// the database. Use it where instant freshness is worth the extra COUNT(*); everywhere +// else the countCacheTTL keeps the value reasonably up to date on its own. +func InvalidateCount(key string) error { + return keyvalue.Del(key) } diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index b0a580c21..83ec34c9b 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -26,8 +26,6 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" - "code.vikunja.io/api/pkg/metrics" - "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/user" @@ -38,16 +36,6 @@ import ( // RegisterListeners registers all event listeners func RegisterListeners() { - if config.MetricsEnabled.GetBool() { - events.RegisterListener((&ProjectCreatedEvent{}).Name(), &IncreaseProjectCounter{}) - events.RegisterListener((&ProjectDeletedEvent{}).Name(), &DecreaseProjectCounter{}) - events.RegisterListener((&TaskCreatedEvent{}).Name(), &IncreaseTaskCounter{}) - events.RegisterListener((&TaskDeletedEvent{}).Name(), &DecreaseTaskCounter{}) - events.RegisterListener((&TeamDeletedEvent{}).Name(), &DecreaseTeamCounter{}) - events.RegisterListener((&TeamCreatedEvent{}).Name(), &IncreaseTeamCounter{}) - events.RegisterListener((&TaskAttachmentCreatedEvent{}).Name(), &IncreaseAttachmentCounter{}) - events.RegisterListener((&TaskAttachmentDeletedEvent{}).Name(), &DecreaseAttachmentCounter{}) - } events.RegisterListener((&TaskCommentCreatedEvent{}).Name(), &SendTaskCommentNotification{}) events.RegisterListener((&TaskAssigneeCreatedEvent{}).Name(), &SendTaskAssignedNotification{}) events.RegisterListener((&TaskDeletedEvent{}).Name(), &SendTaskDeletedNotification{}) @@ -99,34 +87,6 @@ func RegisterListeners() { ////// // Task Events -// IncreaseTaskCounter represents a listener -type IncreaseTaskCounter struct { -} - -// Name defines the name for the IncreaseTaskCounter listener -func (s *IncreaseTaskCounter) Name() string { - return "task.counter.increase" -} - -// Handle is executed when the event IncreaseTaskCounter listens on is fired -func (s *IncreaseTaskCounter) Handle(_ *message.Message) (err error) { - return keyvalue.IncrBy(metrics.TaskCountKey, 1) -} - -// DecreaseTaskCounter represents a listener -type DecreaseTaskCounter struct { -} - -// Name defines the name for the DecreaseTaskCounter listener -func (s *DecreaseTaskCounter) Name() string { - return "task.counter.decrease" -} - -// Handle is executed when the event DecreaseTaskCounter listens on is fired -func (s *DecreaseTaskCounter) Handle(_ *message.Message) (err error) { - return keyvalue.DecrBy(metrics.TaskCountKey, 1) -} - func notifyMentionedUsers(sess *xorm.Session, task *Task, text string, n notifications.NotificationWithSubject) (users map[int64]*user.User, err error) { users, err = FindMentionedUsersInText(sess, text) if err != nil { @@ -583,34 +543,6 @@ func (s *HandleTaskUpdateLastUpdated) Handle(msg *message.Message) (err error) { return sess.Commit() } -// IncreaseAttachmentCounter represents a listener -type IncreaseAttachmentCounter struct { -} - -// Name defines the name for the IncreaseAttachmentCounter listener -func (s *IncreaseAttachmentCounter) Name() string { - return "increase.attachment.counter" -} - -// Handle is executed when the event IncreaseAttachmentCounter listens on is fired -func (s *IncreaseAttachmentCounter) Handle(_ *message.Message) (err error) { - return keyvalue.IncrBy(metrics.AttachmentsCountKey, 1) -} - -// DecreaseAttachmentCounter represents a listener -type DecreaseAttachmentCounter struct { -} - -// Name defines the name for the DecreaseAttachmentCounter listener -func (s *DecreaseAttachmentCounter) Name() string { - return "decrease.attachment.counter" -} - -// Handle is executed when the event DecreaseAttachmentCounter listens on is fired -func (s *DecreaseAttachmentCounter) Handle(_ *message.Message) (err error) { - return keyvalue.DecrBy(metrics.AttachmentsCountKey, 1) -} - // UpdateTaskInSavedFilterViews represents a listener type UpdateTaskInSavedFilterViews struct { } @@ -738,28 +670,6 @@ func (l *UpdateTaskInSavedFilterViews) Handle(msg *message.Message) (err error) /////// // Project Event Listeners -type IncreaseProjectCounter struct { -} - -func (s *IncreaseProjectCounter) Name() string { - return "project.counter.increase" -} - -func (s *IncreaseProjectCounter) Handle(_ *message.Message) (err error) { - return keyvalue.IncrBy(metrics.ProjectCountKey, 1) -} - -type DecreaseProjectCounter struct { -} - -func (s *DecreaseProjectCounter) Name() string { - return "project.counter.decrease" -} - -func (s *DecreaseProjectCounter) Handle(_ *message.Message) (err error) { - return keyvalue.DecrBy(metrics.ProjectCountKey, 1) -} - // SendProjectCreatedNotification represents a listener type SendProjectCreatedNotification struct { } @@ -1259,34 +1169,6 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) { /////// // Team Events -// IncreaseTeamCounter represents a listener -type IncreaseTeamCounter struct { -} - -// Name defines the name for the IncreaseTeamCounter listener -func (s *IncreaseTeamCounter) Name() string { - return "team.counter.increase" -} - -// Handle is executed when the event IncreaseTeamCounter listens on is fired -func (s *IncreaseTeamCounter) Handle(_ *message.Message) (err error) { - return keyvalue.IncrBy(metrics.TeamCountKey, 1) -} - -// DecreaseTeamCounter represents a listener -type DecreaseTeamCounter struct { -} - -// Name defines the name for the DecreaseTeamCounter listener -func (s *DecreaseTeamCounter) Name() string { - return "team.counter.decrease" -} - -// Handle is executed when the event DecreaseTeamCounter listens on is fired -func (s *DecreaseTeamCounter) Handle(_ *message.Message) (err error) { - return keyvalue.DecrBy(metrics.TeamCountKey, 1) -} - // CleanupTaskAssignmentsAfterTeamRemoval represents a listener type CleanupTaskAssignmentsAfterTeamRemoval struct{} diff --git a/pkg/models/metrics_count_test.go b/pkg/models/metrics_count_test.go new file mode 100644 index 000000000..c8f64bac6 --- /dev/null +++ b/pkg/models/metrics_count_test.go @@ -0,0 +1,62 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/metrics" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMetricsCountFromDatabase verifies that each metric key counts the right table +// straight from the database. This guards the count key -> table name mapping; the +// caching/expiry/invalidation behaviour itself is covered by the keyvalue RememberFor +// tests. +func TestMetricsCountFromDatabase(t *testing.T) { + cases := map[string]string{ + metrics.UserCountKey: "users", + metrics.ProjectCountKey: "projects", + metrics.TaskCountKey: "tasks", + metrics.TeamCountKey: "teams", + metrics.FilesCountKey: "files", + metrics.AttachmentsCountKey: "task_attachments", + } + + db.LoadAndAssertFixtures(t) + + s := db.NewSession() + defer s.Close() + + for key, table := range cases { + t.Run(table, func(t *testing.T) { + // Drop any value cached by a previous test so we recompute from the DB. + require.NoError(t, metrics.InvalidateCount(key)) + + expected, err := s.Table(table).Count() + require.NoError(t, err) + + count, err := metrics.GetCount(key) + require.NoError(t, err) + assert.Equal(t, expected, count) + assert.Positive(t, count, "fixtures should contain at least one %s", table) + }) + } +} diff --git a/pkg/modules/keyvalue/keyvalue.go b/pkg/modules/keyvalue/keyvalue.go index 507f7322b..137ea8f62 100644 --- a/pkg/modules/keyvalue/keyvalue.go +++ b/pkg/modules/keyvalue/keyvalue.go @@ -17,6 +17,8 @@ package keyvalue import ( + "time" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/modules/keyvalue/memory" @@ -142,3 +144,38 @@ func RememberValue[T any](key string, fn func() (T, error)) (T, error) { return val, nil } + +// expiringValue wraps a cached value with the time it expires. +type expiringValue[T any] struct { + Value T + ExpiresAt time.Time +} + +// RememberFor is like RememberValue but treats the cached value as stale once it is +// older than ttl. On a miss or once expired, it executes fn, caches the result for +// ttl and returns it. If fn returns an error, nothing is cached. +// T must be a concrete (non-pointer) type. +// +// A value that cannot be deserialized into the expected type is treated as a cache +// miss and overwritten, so the cache self-heals across upgrades that change what a key +// stores (e.g. a key that previously held a plain int64 in Redis). +func RememberFor[T any](key string, ttl time.Duration, fn func() (T, error)) (T, error) { + var cached expiringValue[T] + exists, err := GetWithValue(key, &cached) + if err == nil && exists && time.Now().Before(cached.ExpiresAt) { + return cached.Value, nil + } + + val, err := fn() + if err != nil { + var zero T + return zero, err + } + + if err := Put(key, expiringValue[T]{Value: val, ExpiresAt: time.Now().Add(ttl)}); err != nil { + var zero T + return zero, err + } + + return val, nil +} diff --git a/pkg/modules/keyvalue/keyvalue_test.go b/pkg/modules/keyvalue/keyvalue_test.go index 9d7c10a8b..ca32d8aec 100644 --- a/pkg/modules/keyvalue/keyvalue_test.go +++ b/pkg/modules/keyvalue/keyvalue_test.go @@ -19,6 +19,7 @@ package keyvalue import ( "errors" "testing" + "time" "code.vikunja.io/api/pkg/modules/keyvalue/memory" "github.com/stretchr/testify/assert" @@ -81,3 +82,78 @@ func TestRememberErrorDoesNotStore(t *testing.T) { require.NoError(t, err2) assert.False(t, exists) } + +func TestRememberForReturnsCachedWithinTTL(t *testing.T) { + store = memory.NewStorage() + + called := 0 + fn := func() (int64, error) { + called++ + return int64(called), nil + } + + val, err := RememberFor("foo", time.Hour, fn) + require.NoError(t, err) + assert.Equal(t, int64(1), val) + + // Still within the TTL, so fn must not be called again. + val, err = RememberFor("foo", time.Hour, fn) + require.NoError(t, err) + assert.Equal(t, int64(1), val) + assert.Equal(t, 1, called) +} + +func TestRememberForRecomputesAfterExpiry(t *testing.T) { + store = memory.NewStorage() + + // Seed an already-expired value. + require.NoError(t, Put("foo", expiringValue[int64]{Value: 1, ExpiresAt: time.Now().Add(-time.Minute)})) + + called := 0 + val, err := RememberFor("foo", time.Hour, func() (int64, error) { + called++ + return 2, nil + }) + + require.NoError(t, err) + assert.Equal(t, int64(2), val) + assert.Equal(t, 1, called) +} + +func TestRememberForErrorDoesNotStore(t *testing.T) { + store = memory.NewStorage() + + _, err := RememberFor("foo", time.Hour, func() (int64, error) { + return 0, errors.New("fail") + }) + + require.Error(t, err) + _, exists, err2 := Get("foo") + require.NoError(t, err2) + assert.False(t, exists) +} + +// getWithValueErrorStore simulates a backend that cannot deserialize an existing value +// into the requested type, e.g. a key that held a plain int64 before the cache started +// storing a struct (the pre-refactor metrics counters in Redis). +type getWithValueErrorStore struct { + *memory.Storage +} + +func (s *getWithValueErrorStore) GetWithValue(string, interface{}) (bool, error) { + return false, errors.New("decode error") +} + +func TestRememberForRecomputesWhenStoredValueCannotBeDeserialized(t *testing.T) { + store = &getWithValueErrorStore{memory.NewStorage()} + + called := 0 + val, err := RememberFor("foo", time.Hour, func() (int64, error) { + called++ + return 42, nil + }) + + require.NoError(t, err) + assert.Equal(t, int64(42), val) + assert.Equal(t, 1, called) +} diff --git a/pkg/routes/api/v1/user_register.go b/pkg/routes/api/v1/user_register.go index 96a9c02d9..9db52c88a 100644 --- a/pkg/routes/api/v1/user_register.go +++ b/pkg/routes/api/v1/user_register.go @@ -22,6 +22,8 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/user" @@ -85,5 +87,13 @@ func RegisterUser(c *echo.Context) error { return err } + // Bust the cached user count so the new registration shows up in metrics + // immediately instead of after the regular cache expiry. + if config.MetricsEnabled.GetBool() { + if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil { + log.Errorf("Could not invalidate user count metric: %s", err) + } + } + return c.JSON(http.StatusOK, newUser) } diff --git a/pkg/routes/metrics.go b/pkg/routes/metrics.go index 3b5ba3575..14180ceb4 100644 --- a/pkg/routes/metrics.go +++ b/pkg/routes/metrics.go @@ -20,12 +20,10 @@ import ( "crypto/subtle" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/models" auth2 "code.vikunja.io/api/pkg/modules/auth" - "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" "github.com/labstack/echo/v5/middleware" @@ -39,47 +37,6 @@ func setupMetrics(a *echo.Group) { metrics.InitMetrics() - type countable struct { - Key string - Type interface{} - } - - for _, c := range []countable{ - { - metrics.ProjectCountKey, - models.Project{}, - }, - { - metrics.UserCountKey, - user.User{}, - }, - { - metrics.TaskCountKey, - models.Task{}, - }, - { - metrics.TeamCountKey, - models.Team{}, - }, - { - metrics.FilesCountKey, - files.File{}, - }, - { - metrics.AttachmentsCountKey, - models.TaskAttachment{}, - }, - } { - // Set initial totals - total, err := models.GetTotalCount(c.Type) - if err != nil { - log.Fatalf("Could not get initial count for %v, error was %s", c.Type, err) - } - if err := metrics.SetCount(total, c.Key); err != nil { - log.Fatalf("Could not set initial count for %v, error was %s", c.Type, err) - } - } - r := a.Group("/metrics") if config.MetricsUsername.GetString() != "" && config.MetricsPassword.GetString() != "" { diff --git a/pkg/user/listeners.go b/pkg/user/listeners.go deleted file mode 100644 index 7fdbe61a3..000000000 --- a/pkg/user/listeners.go +++ /dev/null @@ -1,45 +0,0 @@ -// Vikunja is a to-do list application to facilitate your life. -// Copyright 2018-present Vikunja and contributors. All rights reserved. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package user - -import ( - "code.vikunja.io/api/pkg/events" - "code.vikunja.io/api/pkg/metrics" - "code.vikunja.io/api/pkg/modules/keyvalue" - "github.com/ThreeDotsLabs/watermill/message" -) - -func RegisterListeners() { - events.RegisterListener((&CreatedEvent{}).Name(), &IncreaseUserCounter{}) -} - -/////// -// User Events - -// IncreaseUserCounter represents a listener -type IncreaseUserCounter struct { -} - -// Name defines the name for the IncreaseUserCounter listener -func (s *IncreaseUserCounter) Name() string { - return "increase.user.counter" -} - -// Handle is executed when the event IncreaseUserCounter listens on is fired -func (s *IncreaseUserCounter) Handle(_ *message.Message) (err error) { - return keyvalue.IncrBy(metrics.UserCountKey, 1) -} diff --git a/pkg/yaegi_symbols/vikunja_models.go b/pkg/yaegi_symbols/vikunja_models.go index f33da550c..cd2621c81 100644 --- a/pkg/yaegi_symbols/vikunja_models.go +++ b/pkg/yaegi_symbols/vikunja_models.go @@ -315,10 +315,6 @@ func init() { "CleanupTaskAssignmentsAfterTeamRemoval": reflect.ValueOf((*models.CleanupTaskAssignmentsAfterTeamRemoval)(nil)), "DataExportReadyNotification": reflect.ValueOf((*models.DataExportReadyNotification)(nil)), "DatabaseNotifications": reflect.ValueOf((*models.DatabaseNotifications)(nil)), - "DecreaseAttachmentCounter": reflect.ValueOf((*models.DecreaseAttachmentCounter)(nil)), - "DecreaseProjectCounter": reflect.ValueOf((*models.DecreaseProjectCounter)(nil)), - "DecreaseTaskCounter": reflect.ValueOf((*models.DecreaseTaskCounter)(nil)), - "DecreaseTeamCounter": reflect.ValueOf((*models.DecreaseTeamCounter)(nil)), "ErrAPITokenInvalid": reflect.ValueOf((*models.ErrAPITokenInvalid)(nil)), "ErrAttachmentDoesNotBelongToTask": reflect.ValueOf((*models.ErrAttachmentDoesNotBelongToTask)(nil)), "ErrBucketDoesNotBelongToProjectView": reflect.ValueOf((*models.ErrBucketDoesNotBelongToProjectView)(nil)), @@ -405,10 +401,6 @@ func init() { "HandleTaskUpdateLastUpdated": reflect.ValueOf((*models.HandleTaskUpdateLastUpdated)(nil)), "HandleTaskUpdatedMentions": reflect.ValueOf((*models.HandleTaskUpdatedMentions)(nil)), "HandleUserDataExport": reflect.ValueOf((*models.HandleUserDataExport)(nil)), - "IncreaseAttachmentCounter": reflect.ValueOf((*models.IncreaseAttachmentCounter)(nil)), - "IncreaseProjectCounter": reflect.ValueOf((*models.IncreaseProjectCounter)(nil)), - "IncreaseTaskCounter": reflect.ValueOf((*models.IncreaseTaskCounter)(nil)), - "IncreaseTeamCounter": reflect.ValueOf((*models.IncreaseTeamCounter)(nil)), "Label": reflect.ValueOf((*models.Label)(nil)), "LabelByTaskIDsOptions": reflect.ValueOf((*models.LabelByTaskIDsOptions)(nil)), "LabelTask": reflect.ValueOf((*models.LabelTask)(nil)), diff --git a/pkg/yaegi_symbols/vikunja_user.go b/pkg/yaegi_symbols/vikunja_user.go index 7ec6a5e3b..268869221 100644 --- a/pkg/yaegi_symbols/vikunja_user.go +++ b/pkg/yaegi_symbols/vikunja_user.go @@ -99,7 +99,6 @@ func init() { "ListAllUsers": reflect.ValueOf(user.ListAllUsers), "ListUsers": reflect.ValueOf(user.ListUsers), "RegisterDeletionNotificationCron": reflect.ValueOf(user.RegisterDeletionNotificationCron), - "RegisterListeners": reflect.ValueOf(user.RegisterListeners), "RegisterTokenCleanupCron": reflect.ValueOf(user.RegisterTokenCleanupCron), "RequestDeletion": reflect.ValueOf(user.RequestDeletion), "RequestUserPasswordResetToken": reflect.ValueOf(user.RequestUserPasswordResetToken), @@ -159,7 +158,6 @@ func init() { "ErrUsernameReserved": reflect.ValueOf((*user.ErrUsernameReserved)(nil)), "ErrWrongUsernameOrPassword": reflect.ValueOf((*user.ErrWrongUsernameOrPassword)(nil)), "FailedLoginAttemptNotification": reflect.ValueOf((*user.FailedLoginAttemptNotification)(nil)), - "IncreaseUserCounter": reflect.ValueOf((*user.IncreaseUserCounter)(nil)), "InvalidTOTPNotification": reflect.ValueOf((*user.InvalidTOTPNotification)(nil)), "Login": reflect.ValueOf((*user.Login)(nil)), "PasswordAccountLockedAfterInvalidTOTPNotification": reflect.ValueOf((*user.PasswordAccountLockedAfterInvalidTOTPNotification)(nil)),