Add ability to delete old remote views
authorChocobozzz <me@florianbigard.com>
Thu, 11 Apr 2019 15:33:36 +0000 (17:33 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 12 Apr 2019 06:31:06 +0000 (08:31 +0200)
15 files changed:
config/default.yaml
config/production.yaml.example
scripts/parse-log.ts
server.ts
server/controllers/api/server/logs.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/lib/schedulers/remove-old-views-scheduler.ts [new file with mode: 0644]
server/models/video/video-views.ts
server/tests/api/videos/index.ts
server/tests/api/videos/videos-views-cleaner.ts [new file with mode: 0644]
shared/core-utils/logs/logs.ts [new file with mode: 0644]
shared/utils/logs/logs.ts
shared/utils/miscs/sql.ts

index d45d84b90c9af11eb0159d0406ec6dcd1cd19d72..70b10299d5632e40da4a4e9bf356952f6f1d83bb 100644 (file)
@@ -118,6 +118,16 @@ history:
     # Other values could be '6 months' or '30 days' etc (PeerTube will periodically delete old entries from database)
     max_age: -1
 
+views:
+  videos:
+    # PeerTube creates a database entry every hour for each video to track views over a period of time
+    # This is used in particular by the Trending page
+    # PeerTube could remove old remote video views if you want to reduce your database size (video view counter will not be altered)
+    # -1 means no cleanup
+    # Other values could be '6 months' or '30 days' etc (PeerTube will periodically delete old entries from database)
+    remote:
+      max_age: -1
+
 cache:
   previews:
     size: 500 # Max number of previews you want to cache
index b813a65e9cd717fe0340baee09c3742af9ef3bc5..06baaf7d49250687226c2cff9dad2dfc1a2b9c8d 100644 (file)
@@ -119,6 +119,17 @@ history:
     # Other values could be '6 months' or '30 days' etc (PeerTube will periodically delete old entries from database)
     max_age: -1
 
+views:
+  videos:
+    # PeerTube creates a database entry every hour for each video to track views over a period of time
+    # This is used in particular by the Trending page
+    # PeerTube could remove old remote video views if you want to reduce your database size (video view counter will not be altered)
+    # -1 means no cleanup
+    # Other values could be '6 months' or '30 days' etc (PeerTube will periodically delete old entries from database)
+    remote:
+      max_age: -1
+
+
 ###############################################################################
 #
 # From this point, all the following keys can be overridden by the web interface
index fe87db009b685324720a820e5cf884139686d264..83ad45b72a84fa46637023161b123233bde21efc 100755 (executable)
@@ -5,7 +5,7 @@ import { createInterface } from 'readline'
 import * as winston from 'winston'
 import { labelFormatter } from '../server/helpers/logger'
 import { CONFIG } from '../server/initializers/config'
-import { mtimeSortFilesDesc } from '../shared/utils/logs/logs'
+import { mtimeSortFilesDesc } from '../shared/core-utils/logs/logs'
 
 program
   .option('-l, --level [level]', 'Level log (debug/info/warn/error)')
index f4f0c4d68ae2292335981de556c5e6517a2d4520..aa4382ee74f68329447fbb81d855b5cc7acbef6e 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -101,6 +101,7 @@ import {
 import { advertiseDoNotTrack } from './server/middlewares/dnt'
 import { Redis } from './server/lib/redis'
 import { ActorFollowScheduler } from './server/lib/schedulers/actor-follow-scheduler'
+import { RemoveOldViewsScheduler } from './server/lib/schedulers/remove-old-views-scheduler'
 import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler'
 import { UpdateVideosScheduler } from './server/lib/schedulers/update-videos-scheduler'
 import { YoutubeDlUpdateScheduler } from './server/lib/schedulers/youtube-dl-update-scheduler'
@@ -242,6 +243,7 @@ async function startApplication () {
   YoutubeDlUpdateScheduler.Instance.enable()
   VideosRedundancyScheduler.Instance.enable()
   RemoveOldHistoryScheduler.Instance.enable()
+  RemoveOldViewsScheduler.Instance.enable()
 
   // Redis initialization
   Redis.Instance.init()
index 03941cca7709ac2c0b71d7184265b70b75714c4c..e9d1f2efdeca013095f879dd4f195ffd1628af4d 100644 (file)
@@ -1,7 +1,7 @@
 import * as express from 'express'
 import { UserRight } from '../../../../shared/models/users'
 import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../../middlewares'
-import { mtimeSortFilesDesc } from '../../../../shared/utils/logs/logs'
+import { mtimeSortFilesDesc } from '../../../../shared/core-utils/logs/logs'
 import { readdir, readFile } from 'fs-extra'
 import { MAX_LOGS_OUTPUT_CHARACTERS } from '../../../initializers/constants'
 import { join } from 'path'
index 6b43debfb61db295ee8e88ee2a72d617b9ad8228..223ef8078e448b08810c062fb4a0eaa6c9cc92be 100644 (file)
@@ -26,7 +26,8 @@ function checkMissedConfig () {
     'instance.is_nsfw', 'instance.default_nsfw_policy', 'instance.robots', 'instance.securitytxt',
     'services.twitter.username', 'services.twitter.whitelisted',
     'followers.instance.enabled', 'followers.instance.manual_approval',
-    'tracker.enabled', 'tracker.private', 'tracker.reject_too_many_announces'
+    'tracker.enabled', 'tracker.private', 'tracker.reject_too_many_announces',
+    'history.videos.max_age', 'views.videos.remote.max_age'
   ]
   const requiredAlternatives = [
     [ // set
index 1f374dea9de47efd68c3b7c477de5715977d3589..baf5023054a0f49ed63d77c59dc66a52ea8b22b4 100644 (file)
@@ -99,6 +99,13 @@ const CONFIG = {
       MAX_AGE: parseDurationToMs(config.get('history.videos.max_age'))
     }
   },
+  VIEWS: {
+    VIDEOS: {
+      REMOTE: {
+        MAX_AGE: parseDurationToMs(config.get('views.videos.remote.max_age'))
+      }
+    }
+  },
   ADMIN: {
     get EMAIL () { return config.get<string>('admin.email') }
   },
index f008cd291d251894119de4bb669491c333034215..8f6ef1a81d1882af7044d6ef94d988642ab5673d 100644 (file)
@@ -163,6 +163,7 @@ const SCHEDULER_INTERVALS_MS = {
   removeOldJobs: 60000 * 60, // 1 hour
   updateVideos: 60000, // 1 minute
   youtubeDLUpdate: 60000 * 60 * 24, // 1 day
+  removeOldViews: 60000 * 60 * 24, // 1 day
   removeOldHistory: 60000 * 60 * 24 // 1 day
 }
 
@@ -592,6 +593,7 @@ if (isTestInstance() === true) {
   SCHEDULER_INTERVALS_MS.actorFollowScores = 1000
   SCHEDULER_INTERVALS_MS.removeOldJobs = 10000
   SCHEDULER_INTERVALS_MS.removeOldHistory = 5000
+  SCHEDULER_INTERVALS_MS.removeOldViews = 5000
   SCHEDULER_INTERVALS_MS.updateVideos = 5000
   REPEAT_JOBS[ 'videos-views' ] = { every: 5000 }
 
diff --git a/server/lib/schedulers/remove-old-views-scheduler.ts b/server/lib/schedulers/remove-old-views-scheduler.ts
new file mode 100644 (file)
index 0000000..39fbb91
--- /dev/null
@@ -0,0 +1,33 @@
+import { logger } from '../../helpers/logger'
+import { AbstractScheduler } from './abstract-scheduler'
+import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
+import { UserVideoHistoryModel } from '../../models/account/user-video-history'
+import { CONFIG } from '../../initializers/config'
+import { isTestInstance } from '../../helpers/core-utils'
+import { VideoViewModel } from '../../models/video/video-views'
+
+export class RemoveOldViewsScheduler extends AbstractScheduler {
+
+  private static instance: AbstractScheduler
+
+  protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.removeOldViews
+
+  private constructor () {
+    super()
+  }
+
+  protected internalExecute () {
+    if (CONFIG.VIEWS.VIDEOS.REMOTE.MAX_AGE === -1) return
+
+    logger.info('Removing old videos views.')
+
+    const now = new Date()
+    const beforeDate = new Date(now.getTime() - CONFIG.VIEWS.VIDEOS.REMOTE.MAX_AGE).toISOString()
+
+    return VideoViewModel.removeOldRemoteViewsHistory(beforeDate)
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+}
index fde5f7056a0182d1f7500f1747900eebd0ebacff..6071e8c22297ddcbf5127a97df7838a21037236f 100644 (file)
@@ -41,4 +41,18 @@ export class VideoViewModel extends Model<VideoViewModel> {
   })
   Video: VideoModel
 
+  static removeOldRemoteViewsHistory (beforeDate: string) {
+    const query = {
+      where: {
+        startDate: {
+          [Sequelize.Op.lt]: beforeDate
+        },
+        videoId: {
+          [Sequelize.Op.in]: Sequelize.literal('(SELECT "id" FROM "video" WHERE "remote" IS TRUE)')
+        }
+      }
+    }
+
+    return VideoViewModel.destroy(query)
+  }
 }
index 4be12ad15be1d50787a47ac76c28291d72873b14..93e1f3e987e6cb451a439efa65b47caa7b935288 100644 (file)
@@ -18,3 +18,4 @@ import './video-transcoder'
 import './videos-filter'
 import './videos-history'
 import './videos-overview'
+import './videos-views-cleaner'
diff --git a/server/tests/api/videos/videos-views-cleaner.ts b/server/tests/api/videos/videos-views-cleaner.ts
new file mode 100644 (file)
index 0000000..9f268c8
--- /dev/null
@@ -0,0 +1,113 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+  flushAndRunMultipleServers,
+  flushTests,
+  killallServers,
+  reRunServer,
+  runServer,
+  ServerInfo,
+  setAccessTokensToServers,
+  uploadVideo, uploadVideoAndGetId, viewVideo, wait, countVideoViewsOf, doubleFollow, waitJobs
+} from '../../../../shared/utils'
+import { getVideosOverview } from '../../../../shared/utils/overviews/overviews'
+import { VideosOverview } from '../../../../shared/models/overviews'
+import { listMyVideosHistory } from '../../../../shared/utils/videos/video-history'
+
+const expect = chai.expect
+
+describe('Test video views cleaner', function () {
+  let servers: ServerInfo[]
+
+  let videoIdServer1: string
+  let videoIdServer2: string
+
+  before(async function () {
+    this.timeout(50000)
+
+    await flushTests()
+
+    servers = await flushAndRunMultipleServers(2)
+    await setAccessTokensToServers(servers)
+
+    await doubleFollow(servers[0], servers[1])
+
+    videoIdServer1 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video server 1' })).uuid
+    videoIdServer2 = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video server 2' })).uuid
+
+    await waitJobs(servers)
+
+    await viewVideo(servers[0].url, videoIdServer1)
+    await viewVideo(servers[1].url, videoIdServer1)
+    await viewVideo(servers[0].url, videoIdServer2)
+    await viewVideo(servers[1].url, videoIdServer2)
+
+    await waitJobs(servers)
+  })
+
+  it('Should not clean old video views', async function () {
+    this.timeout(50000)
+
+    killallServers([ servers[0] ])
+
+    await reRunServer(servers[0], { views: { videos: { remote: { max_age: '10 days' } } } })
+
+    await wait(6000)
+
+    // Should still have views
+
+    {
+      for (const server of servers) {
+        const total = await countVideoViewsOf(server.serverNumber, videoIdServer1)
+        expect(total).to.equal(2)
+      }
+    }
+
+    {
+      for (const server of servers) {
+        const total = await countVideoViewsOf(server.serverNumber, videoIdServer2)
+        expect(total).to.equal(2)
+      }
+    }
+  })
+
+  it('Should clean old video views', async function () {
+    this.timeout(50000)
+
+    this.timeout(50000)
+
+    killallServers([ servers[0] ])
+
+    await reRunServer(servers[0], { views: { videos: { remote: { max_age: '5 seconds' } } } })
+
+    await wait(6000)
+
+    // Should still have views
+
+    {
+      for (const server of servers) {
+        const total = await countVideoViewsOf(server.serverNumber, videoIdServer1)
+        expect(total).to.equal(2)
+      }
+    }
+
+    {
+      const totalServer1 = await countVideoViewsOf(servers[0].serverNumber, videoIdServer2)
+      expect(totalServer1).to.equal(0)
+
+      const totalServer2 = await countVideoViewsOf(servers[1].serverNumber, videoIdServer2)
+      expect(totalServer2).to.equal(2)
+    }
+  })
+
+  after(async function () {
+    killallServers(servers)
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})
diff --git a/shared/core-utils/logs/logs.ts b/shared/core-utils/logs/logs.ts
new file mode 100644 (file)
index 0000000..d0996cf
--- /dev/null
@@ -0,0 +1,25 @@
+import { stat } from 'fs-extra'
+
+async function mtimeSortFilesDesc (files: string[], basePath: string) {
+  const promises = []
+  const out: { file: string, mtime: number }[] = []
+
+  for (const file of files) {
+    const p = stat(basePath + '/' + file)
+      .then(stats => {
+        if (stats.isFile()) out.push({ file, mtime: stats.mtime.getTime() })
+      })
+
+    promises.push(p)
+  }
+
+  await Promise.all(promises)
+
+  out.sort((a, b) => b.mtime - a.mtime)
+
+  return out
+}
+
+export {
+  mtimeSortFilesDesc
+}
index 21adace82f6d9faa9171e394c25d3e4531cfdaf5..cbb1afb930ab46d614728908d15908941bd91759 100644 (file)
@@ -1,28 +1,6 @@
-// Thanks: https://stackoverflow.com/a/37014317
-import { stat } from 'fs-extra'
 import { makeGetRequest } from '../requests/requests'
 import { LogLevel } from '../../models/server/log-level.type'
 
-async function mtimeSortFilesDesc (files: string[], basePath: string) {
-  const promises = []
-  const out: { file: string, mtime: number }[] = []
-
-  for (const file of files) {
-    const p = stat(basePath + '/' + file)
-      .then(stats => {
-        if (stats.isFile()) out.push({ file, mtime: stats.mtime.getTime() })
-      })
-
-    promises.push(p)
-  }
-
-  await Promise.all(promises)
-
-  out.sort((a, b) => b.mtime - a.mtime)
-
-  return out
-}
-
 function getLogs (url: string, accessToken: string, startDate: Date, endDate?: Date, level?: LogLevel) {
   const path = '/api/v1/server/logs'
 
@@ -36,6 +14,5 @@ function getLogs (url: string, accessToken: string, startDate: Date, endDate?: D
 }
 
 export {
-  mtimeSortFilesDesc,
   getLogs
 }
index 1ce3d801afb4b39f48a2698e3aa129a667d7b540..b281471ce1494de01ac2431520bc4072f9c18b97 100644 (file)
@@ -48,6 +48,20 @@ function setPlaylistField (serverNumber: number, uuid: string, field: string, va
   return seq.query(`UPDATE "videoPlaylist" SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
 }
 
+async function countVideoViewsOf (serverNumber: number, uuid: string) {
+  const seq = getSequelize(serverNumber)
+
+  // tslint:disable
+  const query = `SELECT SUM("videoView"."views") AS "total" FROM "videoView" INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = '${uuid}'`
+
+  const options = { type: Sequelize.QueryTypes.SELECT }
+  const [ { total } ] = await seq.query(query, options)
+
+  if (!total) return 0
+
+  return parseInt(total, 10)
+}
+
 async function closeAllSequelize (servers: any[]) {
   for (let i = 1; i <= servers.length; i++) {
     if (sequelizes[ i ]) {
@@ -61,5 +75,6 @@ export {
   setVideoField,
   setPlaylistField,
   setActorField,
+  countVideoViewsOf,
   closeAllSequelize
 }