Skip to content

Commit 45204e2

Browse files
authored
Schedule backup at regular interval (#1845)
This is a bit less trigger happy than previously, and the implementation is simpler.
1 parent d43d06f commit 45204e2

File tree

6 files changed

+124
-85
lines changed

6 files changed

+124
-85
lines changed

eclair-core/src/main/resources/reference.conf

+6-14
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@ eclair {
1616
password = "" // password for basic auth, must be non empty if json-rpc api is enabled
1717
}
1818

19-
enable-db-backup = true // enable the automatic sqlite db backup; do not change this unless you know what you are doing
20-
// override this with a script/exe that will be called everytime a new database backup has been created
21-
# backup-notify-script = "/absolute/path/to/script.sh"
22-
2319
watch-spent-window = 1 minute // at startup watches will be put back within that window to reduce herd effect; must be > 0s
2420

2521
bitcoind {
@@ -242,16 +238,12 @@ eclair {
242238
}
243239
}
244240

245-
// do not edit or move this section
246-
eclair {
247-
backup-mailbox {
248-
mailbox-type = "akka.dispatch.NonBlockingBoundedMailbox"
249-
mailbox-capacity = 1
250-
}
251-
backup-dispatcher {
252-
executor = "thread-pool-executor"
253-
type = PinnedDispatcher
254-
}
241+
file-backup {
242+
enabled = true // enable the automatic sqlite db backup; do not change this unless you know what you are doing
243+
interval = 10 seconds // interval between two backups
244+
target-file = "eclair.sqlite.bak" // name of the target backup file; will be placed under the chain directory
245+
// override this with a script/exe that will be called everytime a new database backup has been created
246+
# notify-script = "/absolute/path/to/script.sh"
255247
}
256248

257249
akka {

eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala

+4-1
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,10 @@ object NodeParams extends Logging {
192192
// v0.4.3
193193
"min-feerate" -> "on-chain-fees.min-feerate",
194194
"smooth-feerate-window" -> "on-chain-fees.smoothing-window",
195-
"feerate-provider-timeout" -> "on-chain-fees.provider-timeout"
195+
"feerate-provider-timeout" -> "on-chain-fees.provider-timeout",
196+
// v0.6.1
197+
"enable-db-backup" -> "file-backup.enabled",
198+
"backup-notify-script" -> "file-backup.notify-script"
196199
)
197200
deprecatedKeyPaths.foreach {
198201
case (old, new_) => require(!config.hasPath(old), s"configuration key '$old' has been replaced by '$new_'")

eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala

+9-8
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import fr.acinq.eclair.channel.{Channel, Register}
3434
import fr.acinq.eclair.crypto.WeakEntropyPool
3535
import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager}
3636
import fr.acinq.eclair.db.Databases.FileBackup
37+
import fr.acinq.eclair.db.FileBackupHandler.FileBackupParams
3738
import fr.acinq.eclair.db.{Databases, DbEventHandler, FileBackupHandler}
3839
import fr.acinq.eclair.io.{ClientSpawner, Peer, Server, Switchboard}
3940
import fr.acinq.eclair.payment.receive.PaymentHandler
@@ -248,15 +249,15 @@ class Setup(val datadir: File,
248249
wallet = new BitcoinCoreWallet(bitcoin)
249250
_ = wallet.getReceiveAddress().map(address => logger.info(s"initial wallet address=$address"))
250251

251-
// do not change the name of this actor. it is used in the configuration to specify a custom bounded mailbox
252-
backupHandler = if (config.getBoolean("enable-db-backup")) {
252+
_ = if (config.getBoolean("file-backup.enabled")) {
253253
nodeParams.db match {
254-
case fileBackup: FileBackup => system.actorOf(SimpleSupervisor.props(
255-
FileBackupHandler.props(
256-
fileBackup,
257-
new File(chaindir, "eclair.sqlite.bak"),
258-
if (config.hasPath("backup-notify-script")) Some(config.getString("backup-notify-script")) else None),
259-
"backuphandler", SupervisorStrategy.Resume))
254+
case fileBackup: FileBackup if config.getBoolean("file-backup.enabled") =>
255+
val fileBackupParams = FileBackupParams(
256+
interval = FiniteDuration(config.getDuration("file-backup.interval").getSeconds, TimeUnit.SECONDS),
257+
targetFile = new File(chaindir, config.getString("file-backup.target-file")),
258+
script_opt = if (config.hasPath("file-backup.notify-script")) Some(config.getString("file-backup.notify-script")) else None
259+
)
260+
system.spawn(Behaviors.supervise(FileBackupHandler(fileBackup, fileBackupParams)).onFailure(typed.SupervisorStrategy.restart), name = "backuphandler")
260261
case _ =>
261262
system.deadLetters
262263
}

eclair-core/src/main/scala/fr/acinq/eclair/db/FileBackupHandler.scala

+89-54
Original file line numberDiff line numberDiff line change
@@ -16,80 +16,115 @@
1616

1717
package fr.acinq.eclair.db
1818

19-
import akka.actor.{Actor, ActorLogging, Props}
20-
import akka.dispatch.{BoundedMessageQueueSemantics, RequiresMessageQueue}
19+
import akka.Done
20+
import akka.actor.typed.Behavior
21+
import akka.actor.typed.eventstream.EventStream
22+
import akka.actor.typed.scaladsl.{ActorContext, Behaviors}
2123
import fr.acinq.eclair.KamonExt
2224
import fr.acinq.eclair.channel.ChannelPersisted
2325
import fr.acinq.eclair.db.Databases.FileBackup
26+
import fr.acinq.eclair.db.FileBackupHandler._
2427
import fr.acinq.eclair.db.Monitoring.Metrics
2528

2629
import java.io.File
2730
import java.nio.file.{Files, StandardCopyOption}
31+
import java.util.concurrent.Executors
32+
import scala.concurrent.{ExecutionContext, Future}
33+
import scala.concurrent.duration.FiniteDuration
2834
import scala.sys.process.Process
2935
import scala.util.{Failure, Success, Try}
3036

3137

3238
/**
33-
* This actor will synchronously make a backup of the database it was initialized with whenever it receives
34-
* a ChannelPersisted event.
35-
* To avoid piling up messages and entering an endless backup loop, it is supposed to be used with a bounded mailbox
36-
* with a single item:
37-
*
38-
* backup-mailbox {
39-
* mailbox-type = "akka.dispatch.NonBlockingBoundedMailbox"
40-
* mailbox-capacity = 1
41-
* }
42-
*
43-
* Messages that cannot be processed will be sent to dead letters
44-
*
45-
* NB: Constructor is private so users will have to use BackupHandler.props() which always specific a custom mailbox.
46-
*
47-
* @param databases database to backup
48-
* @param backupFile backup file
49-
* @param backupScript_opt (optional) script to execute after the backup completes
39+
* This actor will make a backup of the database it was initialized with at a scheduled interval. It will only
40+
* perform a backup if a ChannelPersisted event was received since the previous backup.
5041
*/
51-
class FileBackupHandler private(databases: FileBackup, backupFile: File, backupScript_opt: Option[String]) extends Actor with RequiresMessageQueue[BoundedMessageQueueSemantics] with ActorLogging {
42+
object FileBackupHandler {
5243

53-
// we listen to ChannelPersisted events, which will trigger a backup
54-
context.system.eventStream.subscribe(self, classOf[ChannelPersisted])
44+
// @formatter:off
5545

56-
def receive: Receive = {
57-
case persisted: ChannelPersisted =>
58-
KamonExt.time(Metrics.FileBackupDuration.withoutTags()) {
59-
val tmpFile = new File(backupFile.getAbsolutePath.concat(".tmp"))
60-
databases.backup(tmpFile)
46+
/**
47+
* @param targetFile backup file
48+
* @param script_opt (optional) script to execute after the backup completes
49+
* @param interval interval between two backups
50+
*/
51+
case class FileBackupParams(interval: FiniteDuration,
52+
targetFile: File,
53+
script_opt: Option[String])
6154

62-
// this will throw an exception if it fails, which is possible if the backup file is not on the same filesystem
63-
// as the temporary file
64-
Files.move(tmpFile.toPath, backupFile.toPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
55+
sealed trait Command
56+
case class WrappedChannelPersisted(wrapped: ChannelPersisted) extends Command
57+
private case object TickBackup extends Command
58+
private case class BackupResult(result: Try[Done]) extends Command
6559

66-
// publish a notification that we have updated our backup
67-
context.system.eventStream.publish(BackupCompleted)
68-
Metrics.FileBackupCompleted.withoutTags().increment()
60+
sealed trait BackupEvent
61+
// this notification is sent when we have completed our backup process (our backup file is ready to be used)
62+
case object BackupCompleted extends BackupEvent
63+
// @formatter:on
64+
65+
// the backup task will run in this thread pool
66+
private val ec = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor())
67+
68+
def apply(databases: FileBackup, backupParams: FileBackupParams): Behavior[Command] =
69+
Behaviors.setup { context =>
70+
// we listen to ChannelPersisted events, which will trigger a backup
71+
context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[ChannelPersisted](WrappedChannelPersisted))
72+
Behaviors.withTimers { timers =>
73+
timers.startTimerAtFixedRate(TickBackup, backupParams.interval)
74+
new FileBackupHandler(databases, backupParams, context).waiting(willBackupAtNextTick = false)
6975
}
76+
}
77+
}
78+
79+
class FileBackupHandler private(databases: FileBackup,
80+
backupParams: FileBackupParams,
81+
context: ActorContext[Command]) {
7082

71-
backupScript_opt.foreach(backupScript => {
72-
Try {
73-
// run the script in the current thread and wait until it terminates
74-
Process(backupScript).!
75-
} match {
76-
case Success(exitCode) => log.debug(s"backup notify script $backupScript returned $exitCode")
77-
case Failure(cause) => log.warning(s"cannot start backup notify script $backupScript: $cause")
83+
def waiting(willBackupAtNextTick: Boolean): Behavior[Command] =
84+
Behaviors.receiveMessagePartial {
85+
case _: WrappedChannelPersisted =>
86+
context.log.debug("will perform backup at next tick")
87+
waiting(willBackupAtNextTick = true)
88+
case TickBackup => if (willBackupAtNextTick) {
89+
context.log.debug("performing backup")
90+
context.pipeToSelf(doBackup())(BackupResult)
91+
backuping(willBackupAtNextTick = false)
92+
} else {
93+
Behaviors.same
94+
}
95+
}
96+
97+
def backuping(willBackupAtNextTick: Boolean): Behavior[Command] =
98+
Behaviors.receiveMessagePartial {
99+
case _: WrappedChannelPersisted =>
100+
context.log.debug("will perform backup at next tick")
101+
backuping(willBackupAtNextTick = true)
102+
case BackupResult(res) =>
103+
res match {
104+
case Success(Done) => context.log.debug("backup succeeded")
105+
case Failure(cause) => context.log.warn(s"backup failed: $cause")
78106
}
79-
})
80-
}
81-
}
107+
waiting(willBackupAtNextTick)
108+
}
82109

83-
sealed trait BackupEvent
110+
private def doBackup(): Future[Done] = Future {
111+
KamonExt.time(Metrics.FileBackupDuration.withoutTags()) {
112+
val tmpFile = new File(backupParams.targetFile.getAbsolutePath.concat(".tmp"))
113+
databases.backup(tmpFile)
84114

85-
// this notification is sent when we have completed our backup process (our backup file is ready to be used)
86-
case object BackupCompleted extends BackupEvent
115+
// this will throw an exception if it fails, which is possible if the backup file is not on the same filesystem
116+
// as the temporary file
117+
Files.move(tmpFile.toPath, backupParams.targetFile.toPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
87118

88-
object FileBackupHandler {
89-
// using this method is the only way to create a BackupHandler actor
90-
// we make sure that it uses a custom bounded mailbox, and a custom pinned dispatcher (i.e our actor will have its own thread pool with 1 single thread)
91-
def props(databases: FileBackup, backupFile: File, backupScript_opt: Option[String]) =
92-
Props(new FileBackupHandler(databases, backupFile, backupScript_opt))
93-
.withMailbox("eclair.backup-mailbox")
94-
.withDispatcher("eclair.backup-dispatcher")
95-
}
119+
// publish a notification that we have updated our backup
120+
context.system.eventStream ! EventStream.Publish(BackupCompleted)
121+
Metrics.FileBackupCompleted.withoutTags().increment()
122+
}
123+
124+
// run the script in the current thread and wait until it terminates
125+
backupParams.script_opt.foreach(backupScript => Process(backupScript).!)
126+
127+
Done
128+
}(ec)
129+
130+
}

eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteFileBackupHandlerSpec.scala

+15-7
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616

1717
package fr.acinq.eclair.db
1818

19+
import akka.actor.typed.scaladsl.adapter.ClassicActorSystemOps
1920
import akka.testkit.TestProbe
2021
import fr.acinq.eclair.channel.ChannelPersisted
2122
import fr.acinq.eclair.db.Databases.FileBackup
23+
import fr.acinq.eclair.db.FileBackupHandler.{BackupCompleted, BackupEvent}
2224
import fr.acinq.eclair.db.sqlite.SqliteChannelsDb
2325
import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec
2426
import fr.acinq.eclair.{TestConstants, TestDatabases, TestKitBaseClass, TestUtils, randomBytes32}
@@ -27,27 +29,33 @@ import org.scalatest.funsuite.AnyFunSuiteLike
2729
import java.io.File
2830
import java.sql.DriverManager
2931
import java.util.UUID
32+
import scala.concurrent.duration.DurationInt
3033

3134
class SqliteFileBackupHandlerSpec extends TestKitBaseClass with AnyFunSuiteLike {
3235

3336
test("process backups") {
3437
val db = TestDatabases.inMemoryDb()
35-
val wip = new File(TestUtils.BUILD_DIRECTORY, s"wip-${UUID.randomUUID()}")
3638
val dest = new File(TestUtils.BUILD_DIRECTORY, s"backup-${UUID.randomUUID()}")
37-
wip.deleteOnExit()
3839
dest.deleteOnExit()
3940
val channel = ChannelCodecsSpec.normal
4041
db.channels.addOrUpdateChannel(channel)
4142
assert(db.channels.listLocalChannels() == Seq(channel))
4243

43-
val handler = system.actorOf(FileBackupHandler.props(db.asInstanceOf[FileBackup], dest, None))
44+
val params = FileBackupHandler.FileBackupParams(
45+
interval = 10 seconds,
46+
targetFile = dest,
47+
script_opt = None
48+
)
49+
50+
val handler = system.spawn(FileBackupHandler(db.asInstanceOf[FileBackup], params), name = "filebackup")
4451
val probe = TestProbe()
4552
system.eventStream.subscribe(probe.ref, classOf[BackupEvent])
4653

47-
handler ! ChannelPersisted(null, TestConstants.Alice.nodeParams.nodeId, randomBytes32(), null)
48-
handler ! ChannelPersisted(null, TestConstants.Alice.nodeParams.nodeId, randomBytes32(), null)
49-
handler ! ChannelPersisted(null, TestConstants.Alice.nodeParams.nodeId, randomBytes32(), null)
50-
probe.expectMsg(BackupCompleted)
54+
handler ! FileBackupHandler.WrappedChannelPersisted(ChannelPersisted(null, TestConstants.Alice.nodeParams.nodeId, randomBytes32(), null))
55+
handler ! FileBackupHandler.WrappedChannelPersisted(ChannelPersisted(null, TestConstants.Alice.nodeParams.nodeId, randomBytes32(), null))
56+
handler ! FileBackupHandler.WrappedChannelPersisted(ChannelPersisted(null, TestConstants.Alice.nodeParams.nodeId, randomBytes32(), null))
57+
probe.expectMsg(20 seconds, BackupCompleted)
58+
probe.expectNoMessage()
5159

5260
val db1 = new SqliteChannelsDb(DriverManager.getConnection(s"jdbc:sqlite:$dest"))
5361
val check = db1.listLocalChannels()

eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ abstract class IntegrationSpec extends TestKitBaseClass with BitcoindService wit
6868

6969
val commonConfig = ConfigFactory.parseMap(Map(
7070
"eclair.chain" -> "regtest",
71-
"eclair.enable-db-backup" -> false,
71+
"eclair.file-backup.enabled" -> false,
7272
"eclair.server.public-ips.1" -> "127.0.0.1",
7373
"eclair.bitcoind.port" -> bitcoindPort,
7474
"eclair.bitcoind.rpcport" -> bitcoindRpcPort,

0 commit comments

Comments
 (0)