@atproto/ozone 0.1.69 → 0.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/api/moderation/queryStatuses.d.ts.map +1 -1
  3. package/dist/api/moderation/queryStatuses.js +1 -33
  4. package/dist/api/moderation/queryStatuses.js.map +1 -1
  5. package/dist/background.d.ts +49 -6
  6. package/dist/background.d.ts.map +1 -1
  7. package/dist/background.js +149 -14
  8. package/dist/background.js.map +1 -1
  9. package/dist/config/config.d.ts +1 -0
  10. package/dist/config/config.d.ts.map +1 -1
  11. package/dist/config/config.js +1 -0
  12. package/dist/config/config.js.map +1 -1
  13. package/dist/config/env.d.ts +1 -0
  14. package/dist/config/env.d.ts.map +1 -1
  15. package/dist/config/env.js +1 -0
  16. package/dist/config/env.js.map +1 -1
  17. package/dist/daemon/context.d.ts +9 -3
  18. package/dist/daemon/context.d.ts.map +1 -1
  19. package/dist/daemon/context.js +33 -3
  20. package/dist/daemon/context.js.map +1 -1
  21. package/dist/daemon/index.d.ts.map +1 -1
  22. package/dist/daemon/index.js +3 -6
  23. package/dist/daemon/index.js.map +1 -1
  24. package/dist/daemon/materialized-view-refresher.d.ts +5 -0
  25. package/dist/daemon/materialized-view-refresher.d.ts.map +1 -0
  26. package/dist/daemon/materialized-view-refresher.js +29 -0
  27. package/dist/daemon/materialized-view-refresher.js.map +1 -0
  28. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.d.ts +5 -0
  29. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.d.ts.map +1 -0
  30. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.js +158 -0
  31. package/dist/db/migrations/20241220T144630860Z-stats-materialized-views.js.map +1 -0
  32. package/dist/db/migrations/index.d.ts +1 -0
  33. package/dist/db/migrations/index.d.ts.map +1 -1
  34. package/dist/db/migrations/index.js +2 -1
  35. package/dist/db/migrations/index.js.map +1 -1
  36. package/dist/db/schema/account_events_stats.d.ts +15 -0
  37. package/dist/db/schema/account_events_stats.d.ts.map +1 -0
  38. package/dist/db/schema/account_events_stats.js +5 -0
  39. package/dist/db/schema/account_events_stats.js.map +1 -0
  40. package/dist/db/schema/account_record_events_stats.d.ts +15 -0
  41. package/dist/db/schema/account_record_events_stats.d.ts.map +1 -0
  42. package/dist/db/schema/account_record_events_stats.js +5 -0
  43. package/dist/db/schema/account_record_events_stats.js.map +1 -0
  44. package/dist/db/schema/account_record_status_stats.d.ts +15 -0
  45. package/dist/db/schema/account_record_status_stats.d.ts.map +1 -0
  46. package/dist/db/schema/account_record_status_stats.js +5 -0
  47. package/dist/db/schema/account_record_status_stats.js.map +1 -0
  48. package/dist/db/schema/index.d.ts +5 -1
  49. package/dist/db/schema/index.d.ts.map +1 -1
  50. package/dist/db/schema/record_events_stats.d.ts +14 -0
  51. package/dist/db/schema/record_events_stats.d.ts.map +1 -0
  52. package/dist/db/schema/record_events_stats.js +5 -0
  53. package/dist/db/schema/record_events_stats.js.map +1 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +1 -4
  56. package/dist/index.js.map +1 -1
  57. package/dist/lexicon/lexicons.d.ts +174 -2
  58. package/dist/lexicon/lexicons.d.ts.map +1 -1
  59. package/dist/lexicon/lexicons.js +92 -1
  60. package/dist/lexicon/lexicons.js.map +1 -1
  61. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts +40 -0
  62. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts.map +1 -1
  63. package/dist/lexicon/types/tools/ozone/moderation/defs.js +20 -0
  64. package/dist/lexicon/types/tools/ozone/moderation/defs.js.map +1 -1
  65. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts +7 -1
  66. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts.map +1 -1
  67. package/dist/mod-service/index.d.ts +4 -62
  68. package/dist/mod-service/index.d.ts.map +1 -1
  69. package/dist/mod-service/index.js +80 -74
  70. package/dist/mod-service/index.js.map +1 -1
  71. package/dist/mod-service/status.d.ts +115 -4
  72. package/dist/mod-service/status.d.ts.map +1 -1
  73. package/dist/mod-service/status.js +51 -34
  74. package/dist/mod-service/status.js.map +1 -1
  75. package/dist/mod-service/types.d.ts +16 -1
  76. package/dist/mod-service/types.d.ts.map +1 -1
  77. package/dist/mod-service/views.d.ts.map +1 -1
  78. package/dist/mod-service/views.js +49 -41
  79. package/dist/mod-service/views.js.map +1 -1
  80. package/dist/util.d.ts +34 -0
  81. package/dist/util.d.ts.map +1 -1
  82. package/dist/util.js +132 -0
  83. package/dist/util.js.map +1 -1
  84. package/package.json +3 -3
  85. package/src/api/moderation/queryStatuses.ts +1 -63
  86. package/src/background.ts +140 -14
  87. package/src/config/config.ts +2 -0
  88. package/src/config/env.ts +4 -0
  89. package/src/daemon/context.ts +43 -5
  90. package/src/daemon/index.ts +3 -6
  91. package/src/daemon/materialized-view-refresher.ts +27 -0
  92. package/src/db/migrations/20241220T144630860Z-stats-materialized-views.ts +218 -0
  93. package/src/db/migrations/index.ts +1 -0
  94. package/src/db/schema/account_events_stats.ts +16 -0
  95. package/src/db/schema/account_record_events_stats.ts +15 -0
  96. package/src/db/schema/account_record_status_stats.ts +15 -0
  97. package/src/db/schema/index.ts +10 -1
  98. package/src/db/schema/record_events_stats.ts +15 -0
  99. package/src/index.ts +1 -7
  100. package/src/lexicon/lexicons.ts +100 -1
  101. package/src/lexicon/types/tools/ozone/moderation/defs.ts +62 -0
  102. package/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts +11 -1
  103. package/src/mod-service/index.ts +181 -118
  104. package/src/mod-service/status.ts +55 -28
  105. package/src/mod-service/types.ts +22 -1
  106. package/src/mod-service/views.ts +64 -50
  107. package/src/util.ts +145 -0
  108. package/tests/__snapshots__/get-record.test.ts.snap +28 -0
  109. package/tests/__snapshots__/get-records.test.ts.snap +14 -0
  110. package/tests/__snapshots__/get-repo.test.ts.snap +11 -0
  111. package/tests/__snapshots__/get-repos.test.ts.snap +11 -0
  112. package/tests/__snapshots__/moderation-events.test.ts.snap +19 -0
  113. package/tests/__snapshots__/moderation-statuses.test.ts.snap +114 -0
  114. package/tests/get-record.test.ts +4 -0
  115. package/tests/get-records.test.ts +4 -0
  116. package/tests/get-repo.test.ts +4 -0
  117. package/tests/get-repos.test.ts +4 -0
  118. package/tests/moderation-events.test.ts +4 -0
  119. package/tests/moderation-statuses.test.ts +4 -0
  120. package/tests/query-labels.test.ts +1 -0
  121. package/tsconfig.build.tsbuildinfo +1 -1
package/src/background.ts CHANGED
@@ -1,35 +1,161 @@
1
1
  import PQueue from 'p-queue'
2
2
  import { Database } from './db'
3
3
  import { dbLogger } from './logger'
4
+ import { boundAbortController, isCausedBySignal, startInterval } from './util'
4
5
 
5
- // A simple queue for in-process, out-of-band/backgrounded work
6
+ type Task = (db: Database, signal: AbortSignal) => Promise<void>
6
7
 
8
+ /**
9
+ * A simple queue for in-process, out-of-band/backgrounded work
10
+ */
7
11
  export class BackgroundQueue {
8
- queue = new PQueue({ concurrency: 20 })
9
- destroyed = false
10
- constructor(public db: Database) {}
12
+ private abortController = new AbortController()
13
+ private queue = new PQueue({ concurrency: 20 })
11
14
 
12
- add(task: Task) {
15
+ public get signal() {
16
+ return this.abortController.signal
17
+ }
18
+
19
+ public get destroyed() {
20
+ return this.signal.aborted
21
+ }
22
+
23
+ constructor(protected db: Database) {}
24
+
25
+ getStats() {
26
+ return {
27
+ runningCount: this.queue.pending,
28
+ waitingCount: this.queue.size,
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Add a task that will be executed at some point in the future.
34
+ *
35
+ * The task will be executed even if the backgroundQueue is destroyed, unless
36
+ * the provided `signal` is aborted.
37
+ *
38
+ * The `signal` provided to the task will be aborted whenever either the
39
+ * backgroundQueue is destroyed or the provided `signal` is aborted.
40
+ */
41
+ async add(task: Task, signal?: AbortSignal): Promise<void> {
13
42
  if (this.destroyed) {
14
43
  return
15
44
  }
16
- this.queue
17
- .add(() => task(this.db))
18
- .catch((err) => {
19
- dbLogger.error(err, 'background queue task failed')
20
- })
45
+
46
+ const abortController = boundAbortController(this.signal, signal)
47
+
48
+ return this.queue.add<void>(async () => {
49
+ try {
50
+ // Do not run the task if the signal provided to the task has become
51
+ // aborted. Do not use `abortController.signal` here since we do not
52
+ // want to abort the task if the backgroundQueue is being destroyed.
53
+ if (signal?.aborted) return
54
+
55
+ // The task will receive a "combined signal" allowing it to abort if
56
+ // either the backgroundQueue is destroyed or the provided signal is
57
+ // aborted.
58
+ await task(this.db, abortController.signal)
59
+ } catch (err) {
60
+ if (!isCausedBySignal(err, abortController.signal)) {
61
+ dbLogger.error(err, 'background queue task failed')
62
+ }
63
+ } finally {
64
+ abortController.abort()
65
+ }
66
+ })
21
67
  }
22
68
 
23
69
  async processAll() {
24
70
  await this.queue.onIdle()
25
71
  }
26
72
 
27
- // On destroy we stop accepting new tasks, but complete all pending/in-progress tasks.
28
- // The application calls this only once http connections have drained (tasks no longer being added).
73
+ /**
74
+ * On destroy we stop accepting new tasks, but complete all
75
+ * pending/in-progress tasks. Tasks can decide to abort their current
76
+ * operation based on the signal they received. The application calls this
77
+ * only once http connections have drained (tasks no longer being added).
78
+ */
29
79
  async destroy() {
30
- this.destroyed = true
80
+ this.abortController.abort()
31
81
  await this.queue.onIdle()
32
82
  }
33
83
  }
34
84
 
35
- type Task = (db: Database) => Promise<void>
85
+ /**
86
+ * A simple periodic background task runner. This class will schedule a task to
87
+ * run through a provided {@link BackgroundQueue} at a fixed interval. The task
88
+ * will never run more than once concurrently, and will wait at least `interval`
89
+ * milliseconds between the end of one run and the start of the next.
90
+ */
91
+ export class PeriodicBackgroundTask {
92
+ private abortController: AbortController
93
+
94
+ private intervalPromise?: Promise<void>
95
+ private runningPromise?: Promise<void>
96
+
97
+ public get signal() {
98
+ return this.abortController.signal
99
+ }
100
+
101
+ public get destroyed() {
102
+ return this.signal.aborted
103
+ }
104
+
105
+ constructor(
106
+ protected backgroundQueue: BackgroundQueue,
107
+ protected interval: number,
108
+ protected task: Task,
109
+ ) {
110
+ if (!Number.isFinite(interval) || interval <= 0) {
111
+ throw new TypeError('interval must be a positive number')
112
+ }
113
+
114
+ // Bind this class's signal to the backgroundQueue's signal (destroying this
115
+ // instance if the backgroundQueue is destroyed)
116
+ this.abortController = boundAbortController(backgroundQueue.signal)
117
+ }
118
+
119
+ public run(signal?: AbortSignal): Promise<void> {
120
+ // `startInterval` already ensures that only one run is in progress at a
121
+ // time. However, we want to be able to expose a `run()` method that can be
122
+ // used to force a run, which could cause concurrent executions. We prevent
123
+ // this using the `runningPromise` property.
124
+
125
+ if (this.runningPromise) return this.runningPromise
126
+
127
+ // Combine the `this.signal` with the provided `signal`, if any.
128
+ const abortController = boundAbortController(this.signal, signal)
129
+
130
+ const promise = this.backgroundQueue.add(this.task, abortController.signal)
131
+
132
+ return (this.runningPromise = promise).finally(() => {
133
+ if (this.runningPromise === promise) this.runningPromise = undefined
134
+
135
+ // Cleanup the listeners added by `boundAbortController`
136
+ abortController.abort()
137
+ })
138
+ }
139
+
140
+ public start() {
141
+ // Noop if already started. Throws if this.signal is aborted (instance is
142
+ // destroyed).
143
+ this.intervalPromise ||= startInterval(
144
+ async (signal) => this.run(signal),
145
+ this.interval,
146
+ this.signal,
147
+ )
148
+ }
149
+
150
+ public async destroy() {
151
+ // @NOTE This instance does not "own" the backgroundQueue, so we do not
152
+ // destroy it here.
153
+
154
+ this.abortController.abort()
155
+ console.error('ABOOOORT')
156
+
157
+ await this.intervalPromise
158
+ this.intervalPromise = undefined
159
+ console.error('DONE -_-')
160
+ }
161
+ }
@@ -24,6 +24,7 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => {
24
24
  poolSize: env.dbPoolSize,
25
25
  poolMaxUses: env.dbPoolMaxUses,
26
26
  poolIdleTimeoutMs: env.dbPoolIdleTimeoutMs,
27
+ materializedViewRefreshIntervalMs: env.dbMaterializedViewRefreshIntervalMs,
27
28
  }
28
29
 
29
30
  assert(env.appviewUrl, 'appviewUrl is required')
@@ -122,6 +123,7 @@ export type DatabaseConfig = {
122
123
  poolSize?: number
123
124
  poolMaxUses?: number
124
125
  poolIdleTimeoutMs?: number
126
+ materializedViewRefreshIntervalMs?: number
125
127
  }
126
128
 
127
129
  export type AppviewConfig = {
package/src/config/env.ts CHANGED
@@ -20,6 +20,9 @@ export const readEnv = (): OzoneEnvironment => {
20
20
  dbPoolSize: envInt('OZONE_DB_POOL_SIZE'),
21
21
  dbPoolMaxUses: envInt('OZONE_DB_POOL_MAX_USES'),
22
22
  dbPoolIdleTimeoutMs: envInt('OZONE_DB_POOL_IDLE_TIMEOUT_MS'),
23
+ dbMaterializedViewRefreshIntervalMs: envInt(
24
+ 'OZONE_DB_MATERIALIZED_VIEW_REFRESH_INTERVAL_MS',
25
+ ),
23
26
  didPlcUrl: envStr('OZONE_DID_PLC_URL'),
24
27
  didCacheStaleTTL: envInt('OZONE_DID_CACHE_STALE_TTL'),
25
28
  didCacheMaxTTL: envInt('OZONE_DID_CACHE_MAX_TTL'),
@@ -53,6 +56,7 @@ export type OzoneEnvironment = {
53
56
  dbPoolSize?: number
54
57
  dbPoolMaxUses?: number
55
58
  dbPoolIdleTimeoutMs?: number
59
+ dbMaterializedViewRefreshIntervalMs?: number
56
60
  didPlcUrl?: string
57
61
  didCacheStaleTTL?: number
58
62
  didCacheMaxTTL?: number
@@ -6,17 +6,20 @@ import { OzoneConfig, OzoneSecrets } from '../config'
6
6
  import { Database } from '../db'
7
7
  import { EventPusher } from './event-pusher'
8
8
  import { EventReverser } from './event-reverser'
9
- import { ModerationService, ModerationServiceCreator } from '../mod-service'
9
+ import { ModerationService } from '../mod-service'
10
10
  import { BackgroundQueue } from '../background'
11
11
  import { getSigningKeyId } from '../util'
12
+ import { MaterializedViewRefresher } from './materialized-view-refresher'
13
+ import { allFulfilled } from '@atproto/common'
12
14
 
13
15
  export type DaemonContextOptions = {
14
16
  db: Database
15
17
  cfg: OzoneConfig
16
- modService: ModerationServiceCreator
18
+ backgroundQueue: BackgroundQueue
17
19
  signingKey: Keypair
18
20
  eventPusher: EventPusher
19
21
  eventReverser: EventReverser
22
+ materializedViewRefresher: MaterializedViewRefresher
20
23
  }
21
24
 
22
25
  export class DaemonContext {
@@ -67,13 +70,19 @@ export class DaemonContext {
67
70
 
68
71
  const eventReverser = new EventReverser(db, modService)
69
72
 
73
+ const materializedViewRefresher = new MaterializedViewRefresher(
74
+ backgroundQueue,
75
+ cfg.db.materializedViewRefreshIntervalMs,
76
+ )
77
+
70
78
  return new DaemonContext({
71
79
  db,
72
80
  cfg,
73
- modService,
81
+ backgroundQueue,
74
82
  signingKey,
75
83
  eventPusher,
76
84
  eventReverser,
85
+ materializedViewRefresher,
77
86
  ...(overrides ?? {}),
78
87
  })
79
88
  }
@@ -86,8 +95,8 @@ export class DaemonContext {
86
95
  return this.opts.cfg
87
96
  }
88
97
 
89
- get modService(): ModerationServiceCreator {
90
- return this.opts.modService
98
+ get backgroundQueue(): BackgroundQueue {
99
+ return this.opts.backgroundQueue
91
100
  }
92
101
 
93
102
  get eventPusher(): EventPusher {
@@ -97,6 +106,35 @@ export class DaemonContext {
97
106
  get eventReverser(): EventReverser {
98
107
  return this.opts.eventReverser
99
108
  }
109
+
110
+ get materializedViewRefresher(): MaterializedViewRefresher {
111
+ return this.opts.materializedViewRefresher
112
+ }
113
+
114
+ async start() {
115
+ this.eventPusher.start()
116
+ this.eventReverser.start()
117
+ this.materializedViewRefresher.start()
118
+ }
119
+
120
+ async processAll() {
121
+ // Sequential because the materialized view values depend on the events.
122
+ await this.eventPusher.processAll()
123
+ await this.materializedViewRefresher.run()
124
+ }
125
+
126
+ async destroy() {
127
+ try {
128
+ await allFulfilled([
129
+ this.eventReverser.destroy(),
130
+ this.eventPusher.destroy(),
131
+ this.materializedViewRefresher.destroy(),
132
+ ])
133
+ } finally {
134
+ await this.backgroundQueue.destroy()
135
+ await this.db.close()
136
+ }
137
+ }
100
138
  }
101
139
 
102
140
  export default DaemonContext
@@ -18,17 +18,14 @@ export class OzoneDaemon {
18
18
  }
19
19
 
20
20
  async start() {
21
- this.ctx.eventPusher.start()
22
- this.ctx.eventReverser.start()
21
+ await this.ctx.start()
23
22
  }
24
23
 
25
24
  async processAll() {
26
- await this.ctx.eventPusher.processAll()
25
+ await this.ctx.processAll()
27
26
  }
28
27
 
29
28
  async destroy() {
30
- await this.ctx.eventReverser.destroy()
31
- await this.ctx.eventPusher.destroy()
32
- await this.ctx.db.close()
29
+ await this.ctx.destroy()
33
30
  }
34
31
  }
@@ -0,0 +1,27 @@
1
+ import { MINUTE } from '@atproto/common'
2
+ import { sql } from 'kysely'
3
+ import { BackgroundQueue, PeriodicBackgroundTask } from '../background'
4
+
5
+ export class MaterializedViewRefresher extends PeriodicBackgroundTask {
6
+ constructor(backgroundQueue: BackgroundQueue, interval = 30 * MINUTE) {
7
+ super(backgroundQueue, interval, async ({ db }, signal) => {
8
+ for (const view of [
9
+ 'account_events_stats',
10
+ 'record_events_stats',
11
+ 'account_record_events_stats',
12
+ 'account_record_status_stats',
13
+ ]) {
14
+ if (signal.aborted) break
15
+
16
+ // Kysely does not provide a way to cancel a running query. Because of
17
+ // this, killing the process during a refresh will cause the process to
18
+ // wait for the current refresh to finish before exiting. This is not
19
+ // ideal, but it is the best we can do until Kysely provides a way to
20
+ // cancel a query.
21
+ await sql`REFRESH MATERIALIZED VIEW CONCURRENTLY ${sql.id(view)}`.execute(
22
+ db,
23
+ )
24
+ }
25
+ })
26
+ }
27
+ }
@@ -0,0 +1,218 @@
1
+ import { Kysely, sql } from 'kysely'
2
+
3
+ import { REASONAPPEAL } from '../../lexicon/types/com/atproto/moderation/defs'
4
+ import { DatabaseSchemaType } from '../schema'
5
+
6
+ import {
7
+ REVIEWESCALATED,
8
+ REVIEWOPEN,
9
+ } from '../../lexicon/types/tools/ozone/moderation/defs'
10
+ import * as modEvent from '../schema/moderation_event'
11
+ import * as modStatus from '../schema/moderation_subject_status'
12
+ import * as recordEventsStats from '../schema/record_events_stats'
13
+
14
+ export async function up(db: Kysely<any>): Promise<void> {
15
+ // Used by "tools.ozone.moderation.queryStatuses". Reduces query cost by two
16
+ // order of magnitudes when sorting using "reportedRecordsCount" or
17
+ // "takendownRecordsCount" and filtering by "reviewState".
18
+ await db.schema
19
+ .createIndex('moderation_subject_status_did_id_review_state_idx')
20
+ .on('moderation_subject_status')
21
+ .column('did')
22
+ .expression(sql`"id" ASC NULLS FIRST`)
23
+ .column('reviewState')
24
+ .execute()
25
+
26
+ // ~6sec for 16M events
27
+ await db.schema
28
+ .createView('account_events_stats')
29
+ .materialized()
30
+ .ifNotExists()
31
+ .as(
32
+ (db as Kysely<modEvent.PartialDB>)
33
+ .selectFrom('moderation_event')
34
+ .where('subjectType', '=', 'com.atproto.admin.defs#repoRef')
35
+ .where('subjectUri', 'is', null)
36
+ .select('subjectDid')
37
+ .select([
38
+ (eb) =>
39
+ sql<number>`COUNT(*) FILTER(
40
+ WHERE ${eb.ref('action')} = 'tools.ozone.moderation.defs#modEventTakedown'
41
+ AND ${eb.ref('durationInHours')} IS NULL
42
+ )`.as('takedownCount'),
43
+ (eb) =>
44
+ sql<number>`COUNT(*) FILTER(
45
+ WHERE ${eb.ref('action')} = 'tools.ozone.moderation.defs#modEventTakedown'
46
+ AND ${eb.ref('durationInHours')} IS NOT NULL
47
+ )`.as('suspendCount'),
48
+ (eb) =>
49
+ sql<number>`COUNT(*) FILTER(
50
+ WHERE ${eb.ref('action')} = 'tools.ozone.moderation.defs#modEventEscalate'
51
+ )`.as('escalateCount'),
52
+ (eb) =>
53
+ sql<number>`COUNT(*) FILTER(
54
+ WHERE ${eb.ref('action')} = 'tools.ozone.moderation.defs#modEventReport'
55
+ AND ${eb.ref('meta')} ->> 'reportType' != ${REASONAPPEAL}
56
+ )`.as('reportCount'),
57
+ (eb) =>
58
+ sql<number>`COUNT(*) FILTER(
59
+ WHERE ${eb.ref('action')} = 'tools.ozone.moderation.defs#modEventReport'
60
+ AND ${eb.ref('meta')} ->> 'reportType' = ${REASONAPPEAL}
61
+ )`.as('appealCount'),
62
+ ])
63
+ .groupBy('subjectDid'),
64
+ )
65
+ .execute()
66
+
67
+ await db.schema
68
+ .createIndex('account_events_stats_did_idx')
69
+ .unique()
70
+ .on('account_events_stats')
71
+ .column('subjectDid')
72
+ .execute()
73
+
74
+ await db.schema
75
+ .createIndex('account_events_stats_suspend_count_idx')
76
+ .on('account_events_stats')
77
+ .expression(sql`"suspendCount" ASC NULLS FIRST`)
78
+ .column('subjectDid')
79
+ .execute()
80
+
81
+ // ~50sec for 16M events
82
+ await db.schema
83
+ .createView('record_events_stats')
84
+ .materialized()
85
+ .ifNotExists()
86
+ .as(
87
+ (db as Kysely<modEvent.PartialDB>)
88
+ .selectFrom('moderation_event')
89
+ .select([
90
+ 'subjectDid',
91
+ 'subjectUri',
92
+ (eb) =>
93
+ sql<number>`COUNT(*) FILTER (WHERE ${eb.ref('action')} = 'tools.ozone.moderation.defs#modEventEscalate')`.as(
94
+ 'escalateCount',
95
+ ),
96
+ (eb) =>
97
+ sql<number>`COUNT(*) FILTER (WHERE ${eb.ref('action')} = 'tools.ozone.moderation.defs#modEventReport' AND ${eb.ref('meta')} ->> 'reportType' != 'com.atproto.moderation.defs#reasonAppeal')`.as(
98
+ 'reportCount',
99
+ ),
100
+ (eb) =>
101
+ sql<number>`COUNT(*) FILTER (WHERE ${eb.ref('action')} = 'tools.ozone.moderation.defs#modEventReport' AND ${eb.ref('meta')} ->> 'reportType' = 'com.atproto.moderation.defs#reasonAppeal')`.as(
102
+ 'appealCount',
103
+ ),
104
+ ])
105
+ .where('subjectType', '=', 'com.atproto.repo.strongRef')
106
+ .where('subjectUri', 'is not', null)
107
+ .groupBy(['subjectDid', 'subjectUri']),
108
+ )
109
+ .execute()
110
+
111
+ await db.schema
112
+ .createIndex('record_events_stats_uri_idx')
113
+ .unique()
114
+ .on('record_events_stats')
115
+ .column('subjectUri')
116
+ .execute()
117
+
118
+ await db.schema
119
+ .createIndex('record_events_stats_did_idx')
120
+ .on('record_events_stats')
121
+ .column('subjectDid')
122
+ .execute()
123
+
124
+ await db.schema
125
+ .createView('account_record_events_stats')
126
+ .materialized()
127
+ .ifNotExists()
128
+ .as(
129
+ (db as Kysely<recordEventsStats.PartialDB>)
130
+ .selectFrom('record_events_stats')
131
+ .select([
132
+ 'subjectDid',
133
+ (eb) =>
134
+ // Casting to "bigint" because "numeric" gets casted to a string
135
+ // by default by postgres-node.
136
+ sql<number>`SUM(${eb.ref('reportCount')})::bigint`.as(
137
+ 'totalReports',
138
+ ),
139
+ (eb) =>
140
+ sql<number>`COUNT(*) FILTER (WHERE ${eb.ref('reportCount')} > 0)`.as(
141
+ 'reportedCount',
142
+ ),
143
+ (eb) =>
144
+ sql<number>`COUNT(*) FILTER (WHERE ${eb.ref('escalateCount')} > 0)`.as(
145
+ 'escalatedCount',
146
+ ),
147
+ (eb) =>
148
+ sql<number>`COUNT(*) FILTER (WHERE ${eb.ref('appealCount')} > 0)`.as(
149
+ 'appealedCount',
150
+ ),
151
+ ])
152
+ .groupBy('subjectDid'),
153
+ )
154
+ .execute()
155
+
156
+ await db.schema
157
+ .createIndex('account_record_events_stats_did_idx')
158
+ .unique()
159
+ .on('account_record_events_stats')
160
+ .column('subjectDid')
161
+ .execute()
162
+
163
+ await db.schema
164
+ .createIndex('account_record_events_stats_reported_count_idx')
165
+ .on('account_record_events_stats')
166
+ .expression(sql`"reportedCount" ASC NULLS FIRST`)
167
+ .column('subjectDid')
168
+ .execute()
169
+
170
+ await db.schema
171
+ .createView('account_record_status_stats')
172
+ .materialized()
173
+ .ifNotExists()
174
+ .as(
175
+ (db as Kysely<modStatus.PartialDB>)
176
+ .selectFrom('moderation_subject_status')
177
+ .select('did')
178
+ .select([
179
+ sql<number>`COUNT(*)`.as('subjectCount'),
180
+ (eb) =>
181
+ sql<number>`COUNT(*) FILTER (WHERE ${eb.ref('reviewState')} IN (${REVIEWOPEN}, ${REVIEWESCALATED}))`.as(
182
+ 'pendingCount',
183
+ ),
184
+ (eb) =>
185
+ sql<number>`COUNT(*) FILTER (WHERE ${eb.ref('reviewState')} NOT IN (${REVIEWOPEN}, ${REVIEWESCALATED}))`.as(
186
+ 'processedCount',
187
+ ),
188
+ (eb) =>
189
+ sql<number>`COUNT(*) FILTER (WHERE ${eb.ref('takendown')})`.as(
190
+ 'takendownCount',
191
+ ),
192
+ ])
193
+ .where('recordPath', '!=', '')
194
+ .groupBy('did'),
195
+ )
196
+ .execute()
197
+
198
+ await db.schema
199
+ .createIndex('account_record_status_stats_did_idx')
200
+ .unique()
201
+ .on('account_record_status_stats')
202
+ .column('did')
203
+ .execute()
204
+
205
+ await db.schema
206
+ .createIndex('account_record_status_stats_takendown_count_idx')
207
+ .on('account_record_status_stats')
208
+ .expression(sql`"takendownCount" ASC NULLS FIRST`)
209
+ .column('did')
210
+ .execute()
211
+ }
212
+
213
+ export async function down(db: Kysely<DatabaseSchemaType>): Promise<void> {
214
+ db.schema.dropView('account_record_status_stats').materialized().execute()
215
+ db.schema.dropView('account_record_events_stats').materialized().execute()
216
+ db.schema.dropView('record_events_stats').materialized().execute()
217
+ db.schema.dropView('account_events_stats').materialized().execute()
218
+ }
@@ -17,3 +17,4 @@ export * as _20241001T205730722Z from './20241001T205730722Z-subject-status-revi
17
17
  export * as _20241008T205730722Z from './20241008T205730722Z-sets'
18
18
  export * as _20241018T205730722Z from './20241018T205730722Z-setting'
19
19
  export * as _20241026T205730722Z from './20241026T205730722Z-add-hosting-status-to-subject-status'
20
+ export * as _20241220T144630860Z from './20241220T144630860Z-stats-materialized-views'
@@ -0,0 +1,16 @@
1
+ import { GeneratedAlways, Selectable } from 'kysely'
2
+
3
+ export const tableName = 'account_events_stats'
4
+
5
+ export type AccountEventsStats = {
6
+ subjectDid: GeneratedAlways<string>
7
+ takedownCount: GeneratedAlways<number>
8
+ suspendCount: GeneratedAlways<number>
9
+ escalateCount: GeneratedAlways<number>
10
+ reportCount: GeneratedAlways<number>
11
+ appealCount: GeneratedAlways<number>
12
+ }
13
+
14
+ export type AccountEventsStatsRow = Selectable<AccountEventsStats>
15
+
16
+ export type PartialDB = { [tableName]: AccountEventsStats }
@@ -0,0 +1,15 @@
1
+ import { GeneratedAlways, Selectable } from 'kysely'
2
+
3
+ export const tableName = 'account_record_events_stats'
4
+
5
+ type AccountRecordEventsStats = {
6
+ subjectDid: GeneratedAlways<string>
7
+ totalReports: GeneratedAlways<number>
8
+ reportedCount: GeneratedAlways<number>
9
+ escalatedCount: GeneratedAlways<number>
10
+ appealedCount: GeneratedAlways<number>
11
+ }
12
+
13
+ export type AccountRecordEventsStatsRow = Selectable<AccountRecordEventsStats>
14
+
15
+ export type PartialDB = { [tableName]: AccountRecordEventsStats }
@@ -0,0 +1,15 @@
1
+ import { GeneratedAlways, Selectable } from 'kysely'
2
+
3
+ export const tableName = 'account_record_status_stats'
4
+
5
+ type AccountRecordStatusStats = {
6
+ did: GeneratedAlways<string>
7
+ subjectCount: GeneratedAlways<number>
8
+ pendingCount: GeneratedAlways<number>
9
+ processedCount: GeneratedAlways<number>
10
+ takendownCount: GeneratedAlways<number>
11
+ }
12
+
13
+ export type AccountRecordStatusStatsRow = Selectable<AccountRecordStatusStats>
14
+
15
+ export type PartialDB = { [tableName]: AccountRecordStatusStats }
@@ -11,6 +11,11 @@ import * as set from './ozone_set'
11
11
  import * as member from './member'
12
12
  import * as setting from './setting'
13
13
 
14
+ import * as recordEventsStats from './record_events_stats'
15
+ import * as accountEventsStats from './account_events_stats'
16
+ import * as accountRecordEventsStats from './account_record_events_stats'
17
+ import * as accountRecordStatusStats from './account_record_status_stats'
18
+
14
19
  export type DatabaseSchemaType = modEvent.PartialDB &
15
20
  modSubjectStatus.PartialDB &
16
21
  label.PartialDB &
@@ -21,7 +26,11 @@ export type DatabaseSchemaType = modEvent.PartialDB &
21
26
  communicationTemplate.PartialDB &
22
27
  set.PartialDB &
23
28
  member.PartialDB &
24
- setting.PartialDB
29
+ setting.PartialDB &
30
+ accountEventsStats.PartialDB &
31
+ recordEventsStats.PartialDB &
32
+ accountRecordEventsStats.PartialDB &
33
+ accountRecordStatusStats.PartialDB
25
34
 
26
35
  export type DatabaseSchema = Kysely<DatabaseSchemaType>
27
36
 
@@ -0,0 +1,15 @@
1
+ import { GeneratedAlways, Selectable } from 'kysely'
2
+
3
+ export const tableName = 'record_events_stats'
4
+
5
+ export type RecordEventsStats = {
6
+ subjectDid: GeneratedAlways<string>
7
+ subjectUri: GeneratedAlways<string>
8
+ escalateCount: GeneratedAlways<number>
9
+ reportCount: GeneratedAlways<number>
10
+ appealCount: GeneratedAlways<number>
11
+ }
12
+
13
+ export type RecordEventsStatsRow = Selectable<RecordEventsStats>
14
+
15
+ export type PartialDB = { [tableName]: RecordEventsStats }
package/src/index.ts CHANGED
@@ -114,13 +114,7 @@ export class OzoneService {
114
114
  },
115
115
  'db pool stats',
116
116
  )
117
- dbLogger.info(
118
- {
119
- runningCount: backgroundQueue.queue.pending,
120
- waitingCount: backgroundQueue.queue.size,
121
- },
122
- 'background queue stats',
123
- )
117
+ dbLogger.info(backgroundQueue.getStats(), 'background queue stats')
124
118
  }, 10000)
125
119
  await this.ctx.sequencer.start()
126
120
  const server = this.app.listen(this.ctx.cfg.service.port)