mirror of https://github.com/waku-org/nwaku.git
feat: Postgres partition implementation (#2506)
* postgres: first step to implement partition management * postgres_driver: use of times.now().toTime().toUnix() instead of Moment.now() * postgres migrations: set new version to 2 * test_driver_postgres: use of assert instead of require and avoid using times.now() * postgres_driver: better implementation of the reset method with partitions * Remove createMessageTable, init, and deleteMessageTable procs * postgres: ensure we use the version 15.4 in tests * postgres_driver.nim: enhance debug logs partition addition * ci.yml: ensure logs are printed without colors * postgres_driver: starting the loop factory in an asynchronous task * postgres_driver: log partition name and size when removing a partition
This commit is contained in:
parent
beba14dcaa
commit
161a10ecb0
|
@ -13,7 +13,7 @@ concurrency:
|
||||||
env:
|
env:
|
||||||
NPROC: 2
|
NPROC: 2
|
||||||
MAKEFLAGS: "-j${NPROC}"
|
MAKEFLAGS: "-j${NPROC}"
|
||||||
NIMFLAGS: "--parallelBuild:${NPROC}"
|
NIMFLAGS: "--parallelBuild:${NPROC} --colors:off -d:chronicles_colors:none"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes: # changes detection
|
changes: # changes detection
|
||||||
|
@ -115,7 +115,7 @@ jobs:
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ${{ runner.os }} == "Linux" ]; then
|
if [ ${{ runner.os }} == "Linux" ]; then
|
||||||
sudo docker run --rm -d -e POSTGRES_PASSWORD=test123 -p 5432:5432 postgres:9.6-alpine
|
sudo docker run --rm -d -e POSTGRES_PASSWORD=test123 -p 5432:5432 postgres:15.4-alpine3.18
|
||||||
fi
|
fi
|
||||||
|
|
||||||
make V=1 LOG_LEVEL=DEBUG QUICK_AND_DIRTY_COMPILER=1 POSTGRES=1 test testwakunode2
|
make V=1 LOG_LEVEL=DEBUG QUICK_AND_DIRTY_COMPILER=1 POSTGRES=1 test testwakunode2
|
||||||
|
|
|
@ -2,7 +2,7 @@ version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:9.6-alpine
|
image: postgres:15.4-alpine3.18
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: test123
|
POSTGRES_PASSWORD: test123
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{.used.}
|
{.used.}
|
||||||
|
|
||||||
import
|
import
|
||||||
std/[sequtils,times,options],
|
std/[sequtils,options],
|
||||||
testutils/unittests,
|
testutils/unittests,
|
||||||
chronos
|
chronos
|
||||||
import
|
import
|
||||||
|
@ -13,8 +13,6 @@ import
|
||||||
../testlib/testasync,
|
../testlib/testasync,
|
||||||
../testlib/postgres
|
../testlib/postgres
|
||||||
|
|
||||||
proc now():int64 = getTime().toUnix()
|
|
||||||
|
|
||||||
proc computeTestCursor(pubsubTopic: PubsubTopic,
|
proc computeTestCursor(pubsubTopic: PubsubTopic,
|
||||||
message: WakuMessage):
|
message: WakuMessage):
|
||||||
ArchiveCursor =
|
ArchiveCursor =
|
||||||
|
@ -56,7 +54,7 @@ suite "Postgres driver":
|
||||||
# Actually, the diff randomly goes between 1 and 2 seconds.
|
# Actually, the diff randomly goes between 1 and 2 seconds.
|
||||||
# although in theory it should spend 1s because we establish 100
|
# although in theory it should spend 1s because we establish 100
|
||||||
# connections and we spawn 100 tasks that spend ~1s each.
|
# connections and we spawn 100 tasks that spend ~1s each.
|
||||||
require diff < 20
|
assert diff < 20_000_000_000
|
||||||
|
|
||||||
asyncTest "Insert a message":
|
asyncTest "Insert a message":
|
||||||
const contentTopic = "test-content-topic"
|
const contentTopic = "test-content-topic"
|
||||||
|
@ -69,14 +67,14 @@ suite "Postgres driver":
|
||||||
assert putRes.isOk(), putRes.error
|
assert putRes.isOk(), putRes.error
|
||||||
|
|
||||||
let storedMsg = (await driver.getAllMessages()).tryGet()
|
let storedMsg = (await driver.getAllMessages()).tryGet()
|
||||||
require:
|
|
||||||
storedMsg.len == 1
|
assert storedMsg.len == 1
|
||||||
storedMsg.all do (item: auto) -> bool:
|
|
||||||
let (pubsubTopic, actualMsg, digest, storeTimestamp) = item
|
let (pubsubTopic, actualMsg, digest, storeTimestamp) = storedMsg[0]
|
||||||
actualMsg.contentTopic == contentTopic and
|
assert actualMsg.contentTopic == contentTopic
|
||||||
pubsubTopic == DefaultPubsubTopic and
|
assert pubsubTopic == DefaultPubsubTopic
|
||||||
toHex(computedDigest.data) == toHex(digest) and
|
assert toHex(computedDigest.data) == toHex(digest)
|
||||||
toHex(actualMsg.payload) == toHex(msg.payload)
|
assert toHex(actualMsg.payload) == toHex(msg.payload)
|
||||||
|
|
||||||
asyncTest "Insert and query message":
|
asyncTest "Insert and query message":
|
||||||
const contentTopic1 = "test-content-topic-1"
|
const contentTopic1 = "test-content-topic-1"
|
||||||
|
@ -96,21 +94,21 @@ suite "Postgres driver":
|
||||||
|
|
||||||
let countMessagesRes = await driver.getMessagesCount()
|
let countMessagesRes = await driver.getMessagesCount()
|
||||||
|
|
||||||
require countMessagesRes.isOk() and countMessagesRes.get() == 2
|
assert countMessagesRes.isOk(), $countMessagesRes.error
|
||||||
|
assert countMessagesRes.get() == 2
|
||||||
|
|
||||||
var messagesRes = await driver.getMessages(contentTopic = @[contentTopic1])
|
var messagesRes = await driver.getMessages(contentTopic = @[contentTopic1])
|
||||||
|
|
||||||
require messagesRes.isOk()
|
assert messagesRes.isOk(), $messagesRes.error
|
||||||
require messagesRes.get().len == 1
|
assert messagesRes.get().len == 1
|
||||||
|
|
||||||
# Get both content topics, check ordering
|
# Get both content topics, check ordering
|
||||||
messagesRes = await driver.getMessages(contentTopic = @[contentTopic1,
|
messagesRes = await driver.getMessages(contentTopic = @[contentTopic1,
|
||||||
contentTopic2])
|
contentTopic2])
|
||||||
assert messagesRes.isOk(), messagesRes.error
|
assert messagesRes.isOk(), messagesRes.error
|
||||||
|
|
||||||
require:
|
assert messagesRes.get().len == 2
|
||||||
messagesRes.get().len == 2 and
|
assert messagesRes.get()[0][1].contentTopic == contentTopic1
|
||||||
messagesRes.get()[0][1].contentTopic == contentTopic1
|
|
||||||
|
|
||||||
# Descending order
|
# Descending order
|
||||||
messagesRes = await driver.getMessages(contentTopic = @[contentTopic1,
|
messagesRes = await driver.getMessages(contentTopic = @[contentTopic1,
|
||||||
|
@ -118,9 +116,8 @@ suite "Postgres driver":
|
||||||
ascendingOrder = false)
|
ascendingOrder = false)
|
||||||
assert messagesRes.isOk(), messagesRes.error
|
assert messagesRes.isOk(), messagesRes.error
|
||||||
|
|
||||||
require:
|
assert messagesRes.get().len == 2
|
||||||
messagesRes.get().len == 2 and
|
assert messagesRes.get()[0][1].contentTopic == contentTopic2
|
||||||
messagesRes.get()[0][1].contentTopic == contentTopic2
|
|
||||||
|
|
||||||
# cursor
|
# cursor
|
||||||
# Get both content topics
|
# Get both content topics
|
||||||
|
@ -130,8 +127,8 @@ suite "Postgres driver":
|
||||||
cursor = some(
|
cursor = some(
|
||||||
computeTestCursor(pubsubTopic1,
|
computeTestCursor(pubsubTopic1,
|
||||||
messagesRes.get()[1][1])))
|
messagesRes.get()[1][1])))
|
||||||
require messagesRes.isOk()
|
assert messagesRes.isOk()
|
||||||
require messagesRes.get().len == 1
|
assert messagesRes.get().len == 1
|
||||||
|
|
||||||
# Get both content topics but one pubsub topic
|
# Get both content topics but one pubsub topic
|
||||||
messagesRes = await driver.getMessages(contentTopic = @[contentTopic1,
|
messagesRes = await driver.getMessages(contentTopic = @[contentTopic1,
|
||||||
|
@ -139,16 +136,15 @@ suite "Postgres driver":
|
||||||
pubsubTopic = some(pubsubTopic1))
|
pubsubTopic = some(pubsubTopic1))
|
||||||
assert messagesRes.isOk(), messagesRes.error
|
assert messagesRes.isOk(), messagesRes.error
|
||||||
|
|
||||||
require:
|
assert messagesRes.get().len == 1
|
||||||
messagesRes.get().len == 1 and
|
assert messagesRes.get()[0][1].contentTopic == contentTopic1
|
||||||
messagesRes.get()[0][1].contentTopic == contentTopic1
|
|
||||||
|
|
||||||
# Limit
|
# Limit
|
||||||
messagesRes = await driver.getMessages(contentTopic = @[contentTopic1,
|
messagesRes = await driver.getMessages(contentTopic = @[contentTopic1,
|
||||||
contentTopic2],
|
contentTopic2],
|
||||||
maxPageSize = 1)
|
maxPageSize = 1)
|
||||||
assert messagesRes.isOk(), messagesRes.error
|
assert messagesRes.isOk(), messagesRes.error
|
||||||
require messagesRes.get().len == 1
|
assert messagesRes.get().len == 1
|
||||||
|
|
||||||
asyncTest "Insert true duplicated messages":
|
asyncTest "Insert true duplicated messages":
|
||||||
# Validates that two completely equal messages can not be stored.
|
# Validates that two completely equal messages can not be stored.
|
||||||
|
@ -164,5 +160,5 @@ suite "Postgres driver":
|
||||||
|
|
||||||
putRes = await driver.put(DefaultPubsubTopic,
|
putRes = await driver.put(DefaultPubsubTopic,
|
||||||
msg2, computeDigest(msg2), computeMessageHash(DefaultPubsubTopic, msg2), msg2.timestamp)
|
msg2, computeDigest(msg2), computeMessageHash(DefaultPubsubTopic, msg2), msg2.timestamp)
|
||||||
require not putRes.isOk()
|
assert not putRes.isOk()
|
||||||
|
|
||||||
|
|
|
@ -73,7 +73,8 @@ method deleteOldestMessagesNotWithinLimit*(driver: ArchiveDriver,
|
||||||
Future[ArchiveDriverResult[void]] {.base, async.} = discard
|
Future[ArchiveDriverResult[void]] {.base, async.} = discard
|
||||||
|
|
||||||
method decreaseDatabaseSize*(driver: ArchiveDriver,
|
method decreaseDatabaseSize*(driver: ArchiveDriver,
|
||||||
targetSizeInBytes: int64):
|
targetSizeInBytes: int64,
|
||||||
|
forceRemoval: bool = false):
|
||||||
Future[ArchiveDriverResult[void]] {.base, async.} = discard
|
Future[ArchiveDriverResult[void]] {.base, async.} = discard
|
||||||
|
|
||||||
method close*(driver: ArchiveDriver):
|
method close*(driver: ArchiveDriver):
|
||||||
|
|
|
@ -108,6 +108,19 @@ proc new*(T: type ArchiveDriver,
|
||||||
if migrateRes.isErr():
|
if migrateRes.isErr():
|
||||||
return err("ArchiveDriver build failed in migration: " & $migrateRes.error)
|
return err("ArchiveDriver build failed in migration: " & $migrateRes.error)
|
||||||
|
|
||||||
|
## This should be started once we make sure the 'messages' table exists
|
||||||
|
## Hence, this should be run after the migration is completed.
|
||||||
|
asyncSpawn driver.startPartitionFactory(onFatalErrorAction)
|
||||||
|
|
||||||
|
info "waiting for a partition to be created"
|
||||||
|
for i in 0..<100:
|
||||||
|
if driver.containsAnyPartition():
|
||||||
|
break
|
||||||
|
await sleepAsync(chronos.milliseconds(100))
|
||||||
|
|
||||||
|
if not driver.containsAnyPartition():
|
||||||
|
onFatalErrorAction("a partition could not be created")
|
||||||
|
|
||||||
return ok(driver)
|
return ok(driver)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -3,6 +3,13 @@ when (NimMajor, NimMinor) < (1, 4):
|
||||||
else:
|
else:
|
||||||
{.push raises: [].}
|
{.push raises: [].}
|
||||||
|
|
||||||
import ./postgres_driver/postgres_driver
|
import
|
||||||
|
./postgres_driver/postgres_driver,
|
||||||
|
./postgres_driver/partitions_manager,
|
||||||
|
./postgres_driver/postgres_healthcheck
|
||||||
|
|
||||||
|
export
|
||||||
|
postgres_driver,
|
||||||
|
partitions_manager,
|
||||||
|
postgres_healthcheck
|
||||||
|
|
||||||
export postgres_driver
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ import
|
||||||
logScope:
|
logScope:
|
||||||
topics = "waku archive migration"
|
topics = "waku archive migration"
|
||||||
|
|
||||||
const SchemaVersion* = 1 # increase this when there is an update in the database schema
|
const SchemaVersion* = 2 # increase this when there is an update in the database schema
|
||||||
|
|
||||||
proc breakIntoStatements*(script: string): seq[string] =
|
proc breakIntoStatements*(script: string): seq[string] =
|
||||||
## Given a full migration script, that can potentially contain a list
|
## Given a full migration script, that can potentially contain a list
|
||||||
|
|
|
@ -0,0 +1,105 @@
|
||||||
|
|
||||||
|
## This module is aimed to handle the creation and truncation of partition tables
|
||||||
|
## in order to limit the space occupied in disk by the database.
|
||||||
|
##
|
||||||
|
## The created partitions are referenced by the 'storedAt' field.
|
||||||
|
##
|
||||||
|
|
||||||
|
import
|
||||||
|
std/deques
|
||||||
|
import
|
||||||
|
chronos,
|
||||||
|
chronicles
|
||||||
|
|
||||||
|
logScope:
|
||||||
|
topics = "waku archive partitions_manager"
|
||||||
|
|
||||||
|
## The time range has seconds resolution
|
||||||
|
type TimeRange* = tuple[beginning: int64, `end`: int64]
|
||||||
|
|
||||||
|
type
|
||||||
|
Partition = object
|
||||||
|
name: string
|
||||||
|
timeRange: TimeRange
|
||||||
|
|
||||||
|
PartitionManager* = ref object
|
||||||
|
partitions: Deque[Partition] # FIFO of partition table names. The first is the oldest partition
|
||||||
|
|
||||||
|
proc new*(T: type PartitionManager): T =
|
||||||
|
return PartitionManager()
|
||||||
|
|
||||||
|
proc getPartitionFromDateTime*(self: PartitionManager,
|
||||||
|
targetMoment: int64):
|
||||||
|
Result[Partition, string] =
|
||||||
|
## Returns the partition name that might store a message containing the passed timestamp.
|
||||||
|
## In order words, it simply returns the partition name which contains the given timestamp.
|
||||||
|
## targetMoment - represents the time of interest, measured in seconds since epoch.
|
||||||
|
|
||||||
|
if self.partitions.len == 0:
|
||||||
|
return err("There are no partitions")
|
||||||
|
|
||||||
|
for partition in self.partitions:
|
||||||
|
let timeRange = partition.timeRange
|
||||||
|
|
||||||
|
let beginning = timeRange.beginning
|
||||||
|
let `end` = timeRange.`end`
|
||||||
|
|
||||||
|
if beginning <= targetMoment and targetMoment < `end`:
|
||||||
|
return ok(partition)
|
||||||
|
|
||||||
|
return err("Couldn't find a partition table for given time: " & $targetMoment)
|
||||||
|
|
||||||
|
proc getNewestPartition*(self: PartitionManager): Result[Partition, string] =
|
||||||
|
if self.partitions.len == 0:
|
||||||
|
return err("there are no partitions allocated")
|
||||||
|
|
||||||
|
let newestPartition = self.partitions.peekLast
|
||||||
|
return ok(newestPartition)
|
||||||
|
|
||||||
|
proc getOldestPartition*(self: PartitionManager): Result[Partition, string] =
|
||||||
|
if self.partitions.len == 0:
|
||||||
|
return err("there are no partitions allocated")
|
||||||
|
|
||||||
|
let oldestPartition = self.partitions.peekFirst
|
||||||
|
return ok(oldestPartition)
|
||||||
|
|
||||||
|
proc addPartitionInfo*(self: PartitionManager,
|
||||||
|
partitionName: string,
|
||||||
|
beginning: int64,
|
||||||
|
`end`: int64) =
|
||||||
|
## The given partition range has seconds resolution.
|
||||||
|
## We just store information of the new added partition merely to keep track of it.
|
||||||
|
let partitionInfo = Partition(name: partitionName, timeRange: (beginning, `end`))
|
||||||
|
trace "Adding partition info"
|
||||||
|
self.partitions.addLast(partitionInfo)
|
||||||
|
|
||||||
|
proc removeOldestPartitionName*(self: PartitionManager) =
|
||||||
|
## Simply removed the partition from the tracked/known partitions queue.
|
||||||
|
## Just remove it and ignore it.
|
||||||
|
discard self.partitions.popFirst()
|
||||||
|
|
||||||
|
proc isEmpty*(self: PartitionManager): bool =
|
||||||
|
return self.partitions.len == 0
|
||||||
|
|
||||||
|
proc getLastMoment*(partition: Partition): int64 =
|
||||||
|
## Considering the time range covered by the partition, this
|
||||||
|
## returns the `end` time (number of seconds since epoch) of such range.
|
||||||
|
let lastTimeInSec = partition.timeRange.`end`
|
||||||
|
return lastTimeInSec
|
||||||
|
|
||||||
|
proc containsMoment*(partition: Partition, time: int64): bool =
|
||||||
|
## Returns true if the given moment is contained within the partition window,
|
||||||
|
## 'false' otherwise.
|
||||||
|
## time - number of seconds since epoch
|
||||||
|
if partition.timeRange.beginning <= time and
|
||||||
|
time < partition.timeRange.`end`:
|
||||||
|
return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
|
||||||
|
proc getName*(partition: Partition): string =
|
||||||
|
return partition.name
|
||||||
|
|
||||||
|
func `==`*(a, b: Partition): bool {.inline.} =
|
||||||
|
return a.name == b.name
|
||||||
|
|
|
@ -4,7 +4,7 @@ else:
|
||||||
{.push raises: [].}
|
{.push raises: [].}
|
||||||
|
|
||||||
import
|
import
|
||||||
std/[nre,options,sequtils,strutils,times,strformat],
|
std/[nre,options,sequtils,strutils,strformat,times],
|
||||||
stew/[results,byteutils],
|
stew/[results,byteutils],
|
||||||
db_postgres,
|
db_postgres,
|
||||||
postgres,
|
postgres,
|
||||||
|
@ -16,33 +16,17 @@ import
|
||||||
../../common,
|
../../common,
|
||||||
../../driver,
|
../../driver,
|
||||||
../../../common/databases/db_postgres as waku_postgres,
|
../../../common/databases/db_postgres as waku_postgres,
|
||||||
./postgres_healthcheck
|
./postgres_healthcheck,
|
||||||
|
./partitions_manager
|
||||||
export postgres_driver
|
|
||||||
|
|
||||||
type PostgresDriver* = ref object of ArchiveDriver
|
type PostgresDriver* = ref object of ArchiveDriver
|
||||||
## Establish a separate pools for read/write operations
|
## Establish a separate pools for read/write operations
|
||||||
writeConnPool: PgAsyncPool
|
writeConnPool: PgAsyncPool
|
||||||
readConnPool: PgAsyncPool
|
readConnPool: PgAsyncPool
|
||||||
|
|
||||||
proc dropTableQuery(): string =
|
## Partition container
|
||||||
"DROP TABLE messages"
|
partitionMngr: PartitionManager
|
||||||
|
futLoopPartitionFactory: Future[void]
|
||||||
proc dropVersionTableQuery(): string =
|
|
||||||
"DROP TABLE version"
|
|
||||||
|
|
||||||
proc createTableQuery(): string =
|
|
||||||
"CREATE TABLE IF NOT EXISTS messages (" &
|
|
||||||
" pubsubTopic VARCHAR NOT NULL," &
|
|
||||||
" contentTopic VARCHAR NOT NULL," &
|
|
||||||
" payload VARCHAR," &
|
|
||||||
" version INTEGER NOT NULL," &
|
|
||||||
" timestamp BIGINT NOT NULL," &
|
|
||||||
" id VARCHAR NOT NULL," &
|
|
||||||
" messageHash VARCHAR NOT NULL," &
|
|
||||||
" storedAt BIGINT NOT NULL," &
|
|
||||||
" CONSTRAINT messageIndex PRIMARY KEY (messageHash)" &
|
|
||||||
");"
|
|
||||||
|
|
||||||
const InsertRowStmtName = "InsertRow"
|
const InsertRowStmtName = "InsertRow"
|
||||||
const InsertRowStmtDefinition =
|
const InsertRowStmtDefinition =
|
||||||
|
@ -111,52 +95,17 @@ proc new*(T: type PostgresDriver,
|
||||||
if not isNil(onFatalErrorAction):
|
if not isNil(onFatalErrorAction):
|
||||||
asyncSpawn checkConnectivity(writeConnPool, onFatalErrorAction)
|
asyncSpawn checkConnectivity(writeConnPool, onFatalErrorAction)
|
||||||
|
|
||||||
return ok(PostgresDriver(writeConnPool: writeConnPool,
|
let driver = PostgresDriver(writeConnPool: writeConnPool,
|
||||||
readConnPool: readConnPool))
|
readConnPool: readConnPool,
|
||||||
|
partitionMngr: PartitionManager.new())
|
||||||
proc performWriteQuery*(s: PostgresDriver,
|
return ok(driver)
|
||||||
query: string): Future[ArchiveDriverResult[void]] {.async.} =
|
|
||||||
## Executes a query that changes the database state
|
|
||||||
## TODO: we can reduce the code a little with this proc
|
|
||||||
(await s.writeConnPool.pgQuery(query)).isOkOr:
|
|
||||||
return err(fmt"error in {query}: {error}")
|
|
||||||
|
|
||||||
return ok()
|
|
||||||
|
|
||||||
proc createMessageTable*(s: PostgresDriver):
|
|
||||||
Future[ArchiveDriverResult[void]] {.async.} =
|
|
||||||
|
|
||||||
let execRes = await s.writeConnPool.pgQuery(createTableQuery())
|
|
||||||
if execRes.isErr():
|
|
||||||
return err("error in createMessageTable: " & execRes.error)
|
|
||||||
|
|
||||||
return ok()
|
|
||||||
|
|
||||||
proc deleteMessageTable(s: PostgresDriver):
|
|
||||||
Future[ArchiveDriverResult[void]] {.async.} =
|
|
||||||
|
|
||||||
let execRes = await s.writeConnPool.pgQuery(dropTableQuery())
|
|
||||||
if execRes.isErr():
|
|
||||||
return err("error in deleteMessageTable: " & execRes.error)
|
|
||||||
|
|
||||||
return ok()
|
|
||||||
|
|
||||||
proc deleteVersionTable(s: PostgresDriver):
|
|
||||||
Future[ArchiveDriverResult[void]] {.async.} =
|
|
||||||
|
|
||||||
let execRes = await s.writeConnPool.pgQuery(dropVersionTableQuery())
|
|
||||||
if execRes.isErr():
|
|
||||||
return err("error in deleteVersionTable: " & execRes.error)
|
|
||||||
|
|
||||||
return ok()
|
|
||||||
|
|
||||||
proc reset*(s: PostgresDriver): Future[ArchiveDriverResult[void]] {.async.} =
|
proc reset*(s: PostgresDriver): Future[ArchiveDriverResult[void]] {.async.} =
|
||||||
## This is only used for testing purposes, to set a fresh database at the beginning of each test
|
## Clear the database partitions
|
||||||
(await s.deleteMessageTable()).isOkOr:
|
let targetSize = 0
|
||||||
return err("error deleting message table: " & $error)
|
let forceRemoval = true
|
||||||
(await s.deleteVersionTable()).isOkOr:
|
let ret = await s.decreaseDatabaseSize(targetSize, forceRemoval)
|
||||||
return err("error deleting version table: " & $error)
|
return ret
|
||||||
return ok()
|
|
||||||
|
|
||||||
proc rowCallbackImpl(pqResult: ptr PGresult,
|
proc rowCallbackImpl(pqResult: ptr PGresult,
|
||||||
outRows: var seq[(PubsubTopic, WakuMessage, seq[byte], Timestamp)]) =
|
outRows: var seq[(PubsubTopic, WakuMessage, seq[byte], Timestamp)]) =
|
||||||
|
@ -219,6 +168,8 @@ method put*(s: PostgresDriver,
|
||||||
let version = $message.version
|
let version = $message.version
|
||||||
let timestamp = $message.timestamp
|
let timestamp = $message.timestamp
|
||||||
|
|
||||||
|
debug "put PostgresDriver", timestamp = timestamp
|
||||||
|
|
||||||
return await s.writeConnPool.runStmt(InsertRowStmtName,
|
return await s.writeConnPool.runStmt(InsertRowStmtName,
|
||||||
InsertRowStmtDefinition,
|
InsertRowStmtDefinition,
|
||||||
@[digest,
|
@[digest,
|
||||||
|
@ -258,6 +209,33 @@ method getAllMessages*(s: PostgresDriver):
|
||||||
|
|
||||||
return ok(rows)
|
return ok(rows)
|
||||||
|
|
||||||
|
proc getPartitionsList(s: PostgresDriver): Future[ArchiveDriverResult[seq[string]]] {.async.} =
|
||||||
|
## Retrieves the seq of partition table names.
|
||||||
|
## e.g: @["messages_1708534333_1708534393", "messages_1708534273_1708534333"]
|
||||||
|
|
||||||
|
var partitions: seq[string]
|
||||||
|
proc rowCallback(pqResult: ptr PGresult) =
|
||||||
|
for iRow in 0..<pqResult.pqNtuples():
|
||||||
|
let partitionName = $(pqgetvalue(pqResult, iRow, 0))
|
||||||
|
partitions.add(partitionName)
|
||||||
|
|
||||||
|
(await s.readConnPool.pgQuery("""
|
||||||
|
SELECT child.relname AS partition_name
|
||||||
|
FROM pg_inherits
|
||||||
|
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
|
||||||
|
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
|
||||||
|
JOIN pg_namespace nmsp_parent ON nmsp_parent.oid = parent.relnamespace
|
||||||
|
JOIN pg_namespace nmsp_child ON nmsp_child.oid = child.relnamespace
|
||||||
|
WHERE parent.relname='messages'
|
||||||
|
""",
|
||||||
|
newSeq[string](0),
|
||||||
|
rowCallback
|
||||||
|
)).isOkOr:
|
||||||
|
|
||||||
|
return err("getPartitionsList failed in query: " & $error)
|
||||||
|
|
||||||
|
return ok(partitions)
|
||||||
|
|
||||||
proc getMessagesArbitraryQuery(s: PostgresDriver,
|
proc getMessagesArbitraryQuery(s: PostgresDriver,
|
||||||
contentTopic: seq[ContentTopic] = @[],
|
contentTopic: seq[ContentTopic] = @[],
|
||||||
pubsubTopic = none(PubsubTopic),
|
pubsubTopic = none(PubsubTopic),
|
||||||
|
@ -428,29 +406,41 @@ method getMessages*(s: PostgresDriver,
|
||||||
maxPageSize,
|
maxPageSize,
|
||||||
ascendingOrder)
|
ascendingOrder)
|
||||||
|
|
||||||
|
proc getStr(s: PostgresDriver,
|
||||||
|
query: string):
|
||||||
|
Future[ArchiveDriverResult[string]] {.async.} =
|
||||||
|
# Performs a query that is expected to return a single string
|
||||||
|
|
||||||
|
var ret: string
|
||||||
|
proc rowCallback(pqResult: ptr PGresult) =
|
||||||
|
if pqResult.pqnfields() != 1:
|
||||||
|
error "Wrong number of fields in getStr"
|
||||||
|
return
|
||||||
|
|
||||||
|
if pqResult.pqNtuples() != 1:
|
||||||
|
error "Wrong number of rows in getStr"
|
||||||
|
return
|
||||||
|
|
||||||
|
ret = $(pqgetvalue(pqResult, 0, 0))
|
||||||
|
|
||||||
|
(await s.readConnPool.pgQuery(query, newSeq[string](0), rowCallback)).isOkOr:
|
||||||
|
return err("failed in getRow: " & $error)
|
||||||
|
|
||||||
|
return ok(ret)
|
||||||
|
|
||||||
proc getInt(s: PostgresDriver,
|
proc getInt(s: PostgresDriver,
|
||||||
query: string):
|
query: string):
|
||||||
Future[ArchiveDriverResult[int64]] {.async.} =
|
Future[ArchiveDriverResult[int64]] {.async.} =
|
||||||
# Performs a query that is expected to return a single numeric value (int64)
|
# Performs a query that is expected to return a single numeric value (int64)
|
||||||
|
|
||||||
var retInt = 0'i64
|
var retInt = 0'i64
|
||||||
proc rowCallback(pqResult: ptr PGresult) =
|
let str = (await s.getStr(query)).valueOr:
|
||||||
if pqResult.pqnfields() != 1:
|
return err("could not get str in getInt: " & $error)
|
||||||
error "Wrong number of fields in getInt"
|
|
||||||
return
|
|
||||||
|
|
||||||
if pqResult.pqNtuples() != 1:
|
|
||||||
error "Wrong number of rows in getInt"
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
retInt = parseInt( $(pqgetvalue(pqResult, 0, 0)) )
|
retInt = parseInt( str )
|
||||||
except ValueError:
|
except ValueError:
|
||||||
error "exception in getInt, parseInt", error = getCurrentExceptionMsg()
|
return err("exception in getInt, parseInt: " & getCurrentExceptionMsg())
|
||||||
return
|
|
||||||
|
|
||||||
(await s.readConnPool.pgQuery(query, newSeq[string](0), rowCallback)).isOkOr:
|
|
||||||
return err("failed in getRow: " & $error)
|
|
||||||
|
|
||||||
return ok(retInt)
|
return ok(retInt)
|
||||||
|
|
||||||
|
@ -518,42 +508,11 @@ method deleteOldestMessagesNotWithinLimit*(
|
||||||
|
|
||||||
return ok()
|
return ok()
|
||||||
|
|
||||||
method decreaseDatabaseSize*(driver: PostgresDriver,
|
|
||||||
targetSizeInBytes: int64):
|
|
||||||
Future[ArchiveDriverResult[void]] {.async.} =
|
|
||||||
## TODO: refactor this implementation and use partition management instead
|
|
||||||
## To remove 20% of the outdated data from database
|
|
||||||
const DeleteLimit = 0.80
|
|
||||||
|
|
||||||
## when db size overshoots the database limit, shread 20% of outdated messages
|
|
||||||
## get size of database
|
|
||||||
let dbSize = (await driver.getDatabaseSize()).valueOr:
|
|
||||||
return err("failed to get database size: " & $error)
|
|
||||||
|
|
||||||
## database size in bytes
|
|
||||||
let totalSizeOfDB: int64 = int64(dbSize)
|
|
||||||
|
|
||||||
if totalSizeOfDB < targetSizeInBytes:
|
|
||||||
return ok()
|
|
||||||
|
|
||||||
## to shread/delete messsges, get the total row/message count
|
|
||||||
let numMessages = (await driver.getMessagesCount()).valueOr:
|
|
||||||
return err("failed to get messages count: " & error)
|
|
||||||
|
|
||||||
## NOTE: Using SQLite vacuuming is done manually, we delete a percentage of rows
|
|
||||||
## if vacumming is done automatically then we aim to check DB size periodially for efficient
|
|
||||||
## retention policy implementation.
|
|
||||||
|
|
||||||
## 80% of the total messages are to be kept, delete others
|
|
||||||
let pageDeleteWindow = int(float(numMessages) * DeleteLimit)
|
|
||||||
|
|
||||||
(await driver.deleteOldestMessagesNotWithinLimit(limit=pageDeleteWindow)).isOkOr:
|
|
||||||
return err("deleting oldest messages failed: " & error)
|
|
||||||
|
|
||||||
return ok()
|
|
||||||
|
|
||||||
method close*(s: PostgresDriver):
|
method close*(s: PostgresDriver):
|
||||||
Future[ArchiveDriverResult[void]] {.async.} =
|
Future[ArchiveDriverResult[void]] {.async.} =
|
||||||
|
## Cancel the partition factory loop
|
||||||
|
s.futLoopPartitionFactory.cancel()
|
||||||
|
|
||||||
## Close the database connection
|
## Close the database connection
|
||||||
let writeCloseRes = await s.writeConnPool.close()
|
let writeCloseRes = await s.writeConnPool.close()
|
||||||
let readCloseRes = await s.readConnPool.close()
|
let readCloseRes = await s.readConnPool.close()
|
||||||
|
@ -587,6 +546,218 @@ proc sleep*(s: PostgresDriver, seconds: int):
|
||||||
|
|
||||||
return ok()
|
return ok()
|
||||||
|
|
||||||
|
proc performWriteQuery*(s: PostgresDriver, query: string):
|
||||||
|
Future[ArchiveDriverResult[void]] {.async.} =
|
||||||
|
## Performs a query that somehow changes the state of the database
|
||||||
|
|
||||||
|
(await s.writeConnPool.pgQuery(query)).isOkOr:
|
||||||
|
return err("error in performWriteQuery: " & $error)
|
||||||
|
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
proc addPartition(self: PostgresDriver,
|
||||||
|
startTime: Timestamp,
|
||||||
|
duration: timer.Duration):
|
||||||
|
Future[ArchiveDriverResult[void]] {.async.} =
|
||||||
|
## Creates a partition table that will store the messages that fall in the range
|
||||||
|
## `startTime` <= storedAt < `startTime + duration`.
|
||||||
|
## `startTime` is measured in seconds since epoch
|
||||||
|
|
||||||
|
let beginning = startTime
|
||||||
|
let `end` = (startTime + duration.seconds)
|
||||||
|
|
||||||
|
let fromInSec: string = $beginning
|
||||||
|
let untilInSec: string = $`end`
|
||||||
|
|
||||||
|
let fromInNanoSec: string = fromInSec & "000000000"
|
||||||
|
let untilInNanoSec: string = untilInSec & "000000000"
|
||||||
|
|
||||||
|
let partitionName = "messages_" & fromInSec & "_" & untilInSec
|
||||||
|
|
||||||
|
let createPartitionQuery = "CREATE TABLE IF NOT EXISTS " & partitionName & " PARTITION OF " &
|
||||||
|
"messages FOR VALUES FROM ('" & fromInNanoSec & "') TO ('" & untilInNanoSec & "');"
|
||||||
|
|
||||||
|
(await self.performWriteQuery(createPartitionQuery)).isOkOr:
|
||||||
|
return err(fmt"error adding partition [{partitionName}]: " & $error)
|
||||||
|
|
||||||
|
debug "new partition added", query = createPartitionQuery
|
||||||
|
|
||||||
|
self.partitionMngr.addPartitionInfo(partitionName, beginning, `end`)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
proc initializePartitionsInfo(self: PostgresDriver): Future[ArchiveDriverResult[void]] {.async.} =
|
||||||
|
let partitionNamesRes = await self.getPartitionsList()
|
||||||
|
if not partitionNamesRes.isOk():
|
||||||
|
return err("Could not retrieve partitions list: " & $partitionNamesRes.error)
|
||||||
|
else:
|
||||||
|
let partitionNames = partitionNamesRes.get()
|
||||||
|
for partitionName in partitionNames:
|
||||||
|
## partitionName contains something like 'messages_1708449815_1708449875'
|
||||||
|
let bothTimes = partitionName.replace("messages_", "")
|
||||||
|
let times = bothTimes.split("_")
|
||||||
|
if times.len != 2:
|
||||||
|
return err(fmt"loopPartitionFactory wrong partition name {partitionName}")
|
||||||
|
|
||||||
|
var beginning: int64
|
||||||
|
try:
|
||||||
|
beginning = parseInt(times[0])
|
||||||
|
except ValueError:
|
||||||
|
return err("Could not parse beginning time: " & getCurrentExceptionMsg())
|
||||||
|
|
||||||
|
var `end`: int64
|
||||||
|
try:
|
||||||
|
`end` = parseInt(times[1])
|
||||||
|
except ValueError:
|
||||||
|
return err("Could not parse end time: " & getCurrentExceptionMsg())
|
||||||
|
|
||||||
|
self.partitionMngr.addPartitionInfo(partitionName, beginning, `end`)
|
||||||
|
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
const DefaultDatabasePartitionCheckTimeInterval = timer.minutes(10)
|
||||||
|
const PartitionsRangeInterval = timer.hours(1) ## Time range covered by each parition
|
||||||
|
|
||||||
|
proc loopPartitionFactory(self: PostgresDriver,
|
||||||
|
onFatalError: OnFatalErrorHandler) {.async.} =
|
||||||
|
## Loop proc that continuously checks whether we need to create a new partition.
|
||||||
|
## Notice that the deletion of partitions is handled by the retention policy modules.
|
||||||
|
|
||||||
|
debug "starting loopPartitionFactory"
|
||||||
|
|
||||||
|
if PartitionsRangeInterval < DefaultDatabasePartitionCheckTimeInterval:
|
||||||
|
onFatalError("partition factory partition range interval should be bigger than check interval")
|
||||||
|
|
||||||
|
## First of all, let's make the 'partition_manager' aware of the current partitions
|
||||||
|
(await self.initializePartitionsInfo()).isOkOr:
|
||||||
|
onFatalError("issue in loopPartitionFactory: " & $error)
|
||||||
|
|
||||||
|
while true:
|
||||||
|
trace "Check if we need to create a new partition"
|
||||||
|
|
||||||
|
let now = times.now().toTime().toUnix()
|
||||||
|
|
||||||
|
if self.partitionMngr.isEmpty():
|
||||||
|
debug "adding partition because now there aren't more partitions"
|
||||||
|
(await self.addPartition(now, PartitionsRangeInterval)).isOkOr:
|
||||||
|
onFatalError("error when creating a new partition from empty state: " & $error)
|
||||||
|
else:
|
||||||
|
let newestPartitionRes = self.partitionMngr.getNewestPartition()
|
||||||
|
if newestPartitionRes.isErr():
|
||||||
|
onFatalError("could not get newest partition: " & $newestPartitionRes.error)
|
||||||
|
|
||||||
|
let newestPartition = newestPartitionRes.get()
|
||||||
|
if newestPartition.containsMoment(now):
|
||||||
|
debug "creating a new partition for the future"
|
||||||
|
## The current used partition is the last one that was created.
|
||||||
|
## Thus, let's create another partition for the future.
|
||||||
|
(await self.addPartition(newestPartition.getLastMoment(), PartitionsRangeInterval)).isOkOr:
|
||||||
|
onFatalError("could not add the next partition for 'now': " & $error)
|
||||||
|
elif now >= newestPartition.getLastMoment():
|
||||||
|
debug "creating a new partition to contain current messages"
|
||||||
|
## There is no partition to contain the current time.
|
||||||
|
## This happens if the node has been stopped for quite a long time.
|
||||||
|
## Then, let's create the needed partition to contain 'now'.
|
||||||
|
(await self.addPartition(now, PartitionsRangeInterval)).isOkOr:
|
||||||
|
onFatalError("could not add the next partition: " & $error)
|
||||||
|
|
||||||
|
await sleepAsync(DefaultDatabasePartitionCheckTimeInterval)
|
||||||
|
|
||||||
|
proc startPartitionFactory*(self: PostgresDriver,
|
||||||
|
onFatalError: OnFatalErrorHandler) {.async.} =
|
||||||
|
|
||||||
|
self.futLoopPartitionFactory = self.loopPartitionFactory(onFatalError)
|
||||||
|
|
||||||
|
proc getTableSize*(self: PostgresDriver,
|
||||||
|
tableName: string): Future[ArchiveDriverResult[string]] {.async.} =
|
||||||
|
## Returns a human-readable representation of the size for the requested table.
|
||||||
|
## tableName - table of interest.
|
||||||
|
|
||||||
|
let tableSize = (await self.getStr(fmt"""
|
||||||
|
SELECT pg_size_pretty(pg_total_relation_size(C.oid)) AS "total_size"
|
||||||
|
FROM pg_class C
|
||||||
|
where relname = '{tableName}'""")).valueOr:
|
||||||
|
return err("error in getDatabaseSize: " & error)
|
||||||
|
|
||||||
|
return ok(tableSize)
|
||||||
|
|
||||||
|
proc removeOldestPartition(self: PostgresDriver,
|
||||||
|
forceRemoval: bool = false, ## To allow cleanup in tests
|
||||||
|
):
|
||||||
|
Future[ArchiveDriverResult[void]] {.async.} =
|
||||||
|
## Indirectly called from the retention policy
|
||||||
|
|
||||||
|
let oldestPartition = self.partitionMngr.getOldestPartition().valueOr:
|
||||||
|
return err("could not remove oldest partition: " & $error)
|
||||||
|
|
||||||
|
if not forceRemoval:
|
||||||
|
let now = times.now().toTime().toUnix()
|
||||||
|
let currentPartitionRes = self.partitionMngr.getPartitionFromDateTime(now)
|
||||||
|
if currentPartitionRes.isOk():
|
||||||
|
## The database contains a partition that would store current messages.
|
||||||
|
|
||||||
|
if currentPartitionRes.get() == oldestPartition:
|
||||||
|
debug "Skipping to remove the current partition"
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
var partSize = ""
|
||||||
|
let partSizeRes = await self.getTableSize(oldestPartition.getName())
|
||||||
|
if partSizeRes.isOk():
|
||||||
|
partSize = partSizeRes.get()
|
||||||
|
|
||||||
|
## In the following lines is where the partition removal happens.
|
||||||
|
## Detach and remove the partition concurrently to not block the parent table (messages)
|
||||||
|
let detachPartitionQuery =
|
||||||
|
"ALTER TABLE messages DETACH PARTITION " & oldestPartition.getName() & " CONCURRENTLY;"
|
||||||
|
debug "removeOldestPartition", query = detachPartitionQuery
|
||||||
|
(await self.performWriteQuery(detachPartitionQuery)).isOkOr:
|
||||||
|
return err(fmt"error in {detachPartitionQuery}: " & $error)
|
||||||
|
|
||||||
|
## Drop the partition
|
||||||
|
let dropPartitionQuery = "DROP TABLE " & oldestPartition.getName()
|
||||||
|
debug "removeOldestPartition drop partition", query = dropPartitionQuery
|
||||||
|
(await self.performWriteQuery(dropPartitionQuery)).isOkOr:
|
||||||
|
return err(fmt"error in {dropPartitionQuery}: " & $error)
|
||||||
|
|
||||||
|
debug "removed partition", partition_name = oldestPartition.getName(), partition_size = partSize
|
||||||
|
self.partitionMngr.removeOldestPartitionName()
|
||||||
|
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
proc containsAnyPartition*(self: PostgresDriver): bool =
|
||||||
|
return not self.partitionMngr.isEmpty()
|
||||||
|
|
||||||
|
method decreaseDatabaseSize*(driver: PostgresDriver,
|
||||||
|
targetSizeInBytes: int64,
|
||||||
|
forceRemoval: bool = false):
|
||||||
|
Future[ArchiveDriverResult[void]] {.async.} =
|
||||||
|
var dbSize = (await driver.getDatabaseSize()).valueOr:
|
||||||
|
return err("decreaseDatabaseSize failed to get database size: " & $error)
|
||||||
|
|
||||||
|
## database size in bytes
|
||||||
|
var totalSizeOfDB: int64 = int64(dbSize)
|
||||||
|
|
||||||
|
if totalSizeOfDB <= targetSizeInBytes:
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
debug "start reducing database size", targetSize = $targetSizeInBytes, currentSize = $totalSizeOfDB
|
||||||
|
|
||||||
|
while totalSizeOfDB > targetSizeInBytes and driver.containsAnyPartition():
|
||||||
|
(await driver.removeOldestPartition(forceRemoval)).isOkOr:
|
||||||
|
return err("decreaseDatabaseSize inside loop failed to remove oldest partition: " & $error)
|
||||||
|
|
||||||
|
dbSize = (await driver.getDatabaseSize()).valueOr:
|
||||||
|
return err("decreaseDatabaseSize inside loop failed to get database size: " & $error)
|
||||||
|
|
||||||
|
let newCurrentSize = int64(dbSize)
|
||||||
|
if newCurrentSize == totalSizeOfDB:
|
||||||
|
return err("the previous partition removal didn't clear database size")
|
||||||
|
|
||||||
|
totalSizeOfDB = newCurrentSize
|
||||||
|
|
||||||
|
debug "reducing database size", targetSize = $targetSizeInBytes, newCurrentSize = $totalSizeOfDB
|
||||||
|
|
||||||
|
return ok()
|
||||||
|
|
||||||
method existsTable*(s: PostgresDriver, tableName: string):
|
method existsTable*(s: PostgresDriver, tableName: string):
|
||||||
Future[ArchiveDriverResult[bool]] {.async.} =
|
Future[ArchiveDriverResult[bool]] {.async.} =
|
||||||
let query: string = fmt"""
|
let query: string = fmt"""
|
||||||
|
@ -628,3 +799,5 @@ proc getCurrentVersion*(s: PostgresDriver):
|
||||||
return err("error in getMessagesCount: " & $error)
|
return err("error in getMessagesCount: " & $error)
|
||||||
|
|
||||||
return ok(res)
|
return ok(res)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -322,7 +322,8 @@ method deleteOldestMessagesNotWithinLimit*(driver: QueueDriver,
|
||||||
return err("interface method not implemented")
|
return err("interface method not implemented")
|
||||||
|
|
||||||
method decreaseDatabaseSize*(driver: QueueDriver,
|
method decreaseDatabaseSize*(driver: QueueDriver,
|
||||||
targetSizeInBytes: int64):
|
targetSizeInBytes: int64,
|
||||||
|
forceRemoval: bool = false):
|
||||||
Future[ArchiveDriverResult[void]] {.async.} =
|
Future[ArchiveDriverResult[void]] {.async.} =
|
||||||
return err("interface method not implemented")
|
return err("interface method not implemented")
|
||||||
|
|
||||||
|
|
|
@ -147,7 +147,8 @@ method deleteOldestMessagesNotWithinLimit*(s: SqliteDriver,
|
||||||
return s.db.deleteOldestMessagesNotWithinLimit(limit)
|
return s.db.deleteOldestMessagesNotWithinLimit(limit)
|
||||||
|
|
||||||
method decreaseDatabaseSize*(driver: SqliteDriver,
|
method decreaseDatabaseSize*(driver: SqliteDriver,
|
||||||
targetSizeInBytes: int64):
|
targetSizeInBytes: int64,
|
||||||
|
forceRemoval: bool = false):
|
||||||
Future[ArchiveDriverResult[void]] {.async.} =
|
Future[ArchiveDriverResult[void]] {.async.} =
|
||||||
|
|
||||||
## To remove 20% of the outdated data from database
|
## To remove 20% of the outdated data from database
|
||||||
|
|
Loading…
Reference in New Issue