Skip to content

Commit 33d52b6

Browse files
authored
More database nits (#1773)
* use prepared statements for pruning * optional read-only user It is good practice to create a dedicated read-only user to browse the database safely. But since the app itself creates its tables, the postgres user is the owner and a manual `GRANT` is required everytime a new table is added. This PR makes it possible to specify an arbitrary username, that will be granted read-only access to all tables in the `public` schema. NB: The assumption here is that eclair is the only app using the eclair database (in the `CREATE DATABASE eclair` sense), which I believe is reasonable. * set timezone on lease table This only affects newly created table, there is no migration. Users that are already using postgres will keep the previous column type, it doesn't change anything for them. * put back lock timeout on lease table We use a timeout, because due to concurrency we may not be able to obtain a lock immediately. The timeout has been set to its original value of 5s and made configurable. * obtain lock before initializing tables
1 parent 32a86a4 commit 33d52b6

File tree

9 files changed

+67
-33
lines changed

9 files changed

+67
-33
lines changed

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -221,17 +221,19 @@ eclair {
221221
port = 5432
222222
username = ""
223223
password = ""
224+
readonly-user = "" // if defined, this user will be granted read-only access to all tables in the database
224225
pool {
225226
max-size = 10 // recommended value = number_of_cpu_cores * 2
226227
connection-timeout = 30 seconds
227228
idle-timeout = 10 minutes
228229
max-life-time = 30 minutes
229230
}
231+
lock-type = "lease" // lease or none (do not use none in production)
230232
lease {
231233
interval = 5 minutes // lease-interval must be greater than lease-renew-interval
232234
renew-interval = 1 minute
235+
lock-timeout = 5 seconds // timeout for the lock statement on the lease table
233236
}
234-
lock-type = "lease" // lease or none
235237
}
236238
}
237239
}

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

+26-10
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,24 @@ object Databases extends Logging {
9595
def apply(hikariConfig: HikariConfig,
9696
instanceId: UUID,
9797
lock: PgLock = PgLock.NoLock,
98-
jdbcUrlFile_opt: Option[File])(implicit system: ActorSystem): PostgresDatabases = {
98+
jdbcUrlFile_opt: Option[File],
99+
readOnlyUser_opt: Option[String])(implicit system: ActorSystem): PostgresDatabases = {
99100

100101
jdbcUrlFile_opt.foreach(jdbcUrlFile => checkIfDatabaseUrlIsUnchanged(hikariConfig.getJdbcUrl, jdbcUrlFile))
101102

102103
implicit val ds: HikariDataSource = new HikariDataSource(hikariConfig)
103104
implicit val implicitLock: PgLock = lock
104105

106+
lock match {
107+
case PgLock.NoLock => ()
108+
case l: PgLock.LeaseLock =>
109+
// we obtain a lock right now...
110+
l.obtainExclusiveLock(ds)
111+
// ...and renew the lease regularly
112+
import system.dispatcher
113+
system.scheduler.scheduleWithFixedDelay(l.leaseRenewInterval, l.leaseRenewInterval)(() => l.obtainExclusiveLock(ds))
114+
}
115+
105116
val databases = PostgresDatabases(
106117
network = new PgNetworkDb,
107118
audit = new PgAuditDb,
@@ -112,14 +123,13 @@ object Databases extends Logging {
112123
dataSource = ds,
113124
lock = lock)
114125

115-
lock match {
116-
case PgLock.NoLock => ()
117-
case l: PgLock.LeaseLock =>
118-
// we obtain a lock right now...
119-
databases.obtainExclusiveLock()
120-
// ...and renew the lease regularly
121-
import system.dispatcher
122-
system.scheduler.scheduleWithFixedDelay(l.leaseRenewInterval, l.leaseRenewInterval)(() => databases.obtainExclusiveLock())
126+
readOnlyUser_opt.foreach { readOnlyUser =>
127+
PgUtils.inTransaction { connection =>
128+
using(connection.createStatement()) { statement =>
129+
logger.info(s"granting read-only access to user=$readOnlyUser")
130+
statement.executeUpdate(s"GRANT SELECT ON ALL TABLES IN SCHEMA public TO $readOnlyUser")
131+
}
132+
}
123133
}
124134

125135
databases
@@ -183,6 +193,7 @@ object Databases extends Logging {
183193
val port = dbConfig.getInt("postgres.port")
184194
val username = if (dbConfig.getIsNull("postgres.username") || dbConfig.getString("postgres.username").isEmpty) None else Some(dbConfig.getString("postgres.username"))
185195
val password = if (dbConfig.getIsNull("postgres.password") || dbConfig.getString("postgres.password").isEmpty) None else Some(dbConfig.getString("postgres.password"))
196+
val readOnlyUser_opt = if (dbConfig.getIsNull("postgres.readonly-user") || dbConfig.getString("postgres.readonly-user").isEmpty) None else Some(dbConfig.getString("postgres.readonly-user"))
186197

187198
val hikariConfig = new HikariConfig()
188199
hikariConfig.setJdbcUrl(s"jdbc:postgresql://$host:$port/$database")
@@ -200,6 +211,10 @@ object Databases extends Logging {
200211
val leaseInterval = dbConfig.getDuration("postgres.lease.interval").toSeconds.seconds
201212
val leaseRenewInterval = dbConfig.getDuration("postgres.lease.renew-interval").toSeconds.seconds
202213
require(leaseInterval > leaseRenewInterval, "invalid configuration: `db.postgres.lease.interval` must be greater than `db.postgres.lease.renew-interval`")
214+
// We use a timeout for locks, because we might not be able to get the lock right away due to concurrent access
215+
// by other threads. That timeout gives time for other transactions to complete, then ours can take the lock
216+
val lockTimeout = dbConfig.getDuration("postgres.lease.lock-timeout").toSeconds.seconds
217+
hikariConfig.setConnectionInitSql(s"SET lock_timeout TO '${lockTimeout.toSeconds}s'")
203218
PgLock.LeaseLock(instanceId, leaseInterval, leaseRenewInterval, lockExceptionHandler)
204219
case unknownLock => throw new RuntimeException(s"unknown postgres lock type: `$unknownLock`")
205220
}
@@ -210,7 +225,8 @@ object Databases extends Logging {
210225
hikariConfig = hikariConfig,
211226
instanceId = instanceId,
212227
lock = lock,
213-
jdbcUrlFile_opt = Some(jdbcUrlFile)
228+
jdbcUrlFile_opt = Some(jdbcUrlFile),
229+
readOnlyUser_opt = readOnlyUser_opt
214230
)
215231
}
216232

eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgNetworkDb.scala

+12-7
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,17 @@ class PgNetworkDb(implicit ds: DataSource) extends NetworkDb with Logging {
139139
}
140140

141141
override def removeChannels(shortChannelIds: Iterable[ShortChannelId]): Unit = withMetrics("network/remove-channels", DbBackends.Postgres) {
142+
val batchSize = 100
142143
inTransaction { pg =>
143-
using(pg.createStatement) { statement =>
144+
using(pg.prepareStatement(s"DELETE FROM channels WHERE short_channel_id IN (${List.fill(batchSize)("?").mkString(",")})")) { statement =>
144145
shortChannelIds
145-
.grouped(1000) // remove channels by batch of 1000
146-
.foreach { _ =>
147-
val ids = shortChannelIds.map(_.toLong).mkString(",")
148-
statement.executeUpdate(s"DELETE FROM channels WHERE short_channel_id IN ($ids)")
146+
.grouped(batchSize)
147+
.foreach { group =>
148+
val padded = group.toArray.padTo(batchSize, ShortChannelId(0L))
149+
for (i <- 0 until batchSize) {
150+
statement.setLong(1 + i, padded(i).toLong) // index for jdbc parameters starts at 1
151+
}
152+
statement.executeUpdate()
149153
}
150154
}
151155
}
@@ -165,8 +169,9 @@ class PgNetworkDb(implicit ds: DataSource) extends NetworkDb with Logging {
165169

166170
override def removeFromPruned(shortChannelId: ShortChannelId): Unit = withMetrics("network/remove-from-pruned", DbBackends.Postgres) {
167171
inTransaction { pg =>
168-
using(pg.createStatement) { statement =>
169-
statement.executeUpdate(s"DELETE FROM pruned WHERE short_channel_id=${shortChannelId.toLong}")
172+
using(pg.prepareStatement(s"DELETE FROM pruned WHERE short_channel_id=?")) { statement =>
173+
statement.setLong(1, shortChannelId.toLong)
174+
statement.executeUpdate()
170175
}
171176
}
172177
}

eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgUtils.scala

+6-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package fr.acinq.eclair.db.pg
1818

19+
import fr.acinq.eclair.db.Monitoring.Metrics._
20+
import fr.acinq.eclair.db.Monitoring.Tags
1921
import fr.acinq.eclair.db.jdbc.JdbcUtils
2022
import fr.acinq.eclair.db.pg.PgUtils.PgLock.LockFailureHandler.LockException
2123
import grizzled.slf4j.Logging
@@ -177,14 +179,16 @@ object PgUtils extends JdbcUtils {
177179
using(connection.createStatement()) {
178180
statement =>
179181
// allow only one row in the ownership lease table
180-
statement.executeUpdate(s"CREATE TABLE IF NOT EXISTS $LeaseTable (id INTEGER PRIMARY KEY default(1), expires_at TIMESTAMP NOT NULL, instance VARCHAR NOT NULL, CONSTRAINT one_row CHECK (id = 1))")
182+
statement.executeUpdate(s"CREATE TABLE IF NOT EXISTS $LeaseTable (id INTEGER PRIMARY KEY default(1), expires_at TIMESTAMP WITH TIME ZONE NOT NULL, instance VARCHAR NOT NULL, CONSTRAINT one_row CHECK (id = 1))")
181183
}
182184
}
183185

184186
private def acquireExclusiveTableLock()(implicit connection: Connection): Unit = {
185187
using(connection.createStatement()) {
186188
statement =>
187-
statement.executeUpdate(s"LOCK TABLE $LeaseTable IN ACCESS EXCLUSIVE MODE NOWAIT")
189+
withMetrics("utils/lock", Tags.DbBackends.Postgres) {
190+
statement.executeUpdate(s"LOCK TABLE $LeaseTable IN ACCESS EXCLUSIVE MODE")
191+
}
188192
}
189193
}
190194

eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteNetworkDb.scala

+13-8
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
package fr.acinq.eclair.db.sqlite
1818

19-
import java.sql.Connection
2019
import fr.acinq.bitcoin.{ByteVector32, Crypto, Satoshi}
2120
import fr.acinq.eclair.ShortChannelId
2221
import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics
@@ -27,6 +26,7 @@ import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.{channelAnnouncement
2726
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate, NodeAnnouncement}
2827
import grizzled.slf4j.Logging
2928

29+
import java.sql.Connection
3030
import scala.collection.immutable.SortedMap
3131

3232
class SqliteNetworkDb(sqlite: Connection) extends NetworkDb with Logging {
@@ -132,12 +132,16 @@ class SqliteNetworkDb(sqlite: Connection) extends NetworkDb with Logging {
132132
}
133133

134134
override def removeChannels(shortChannelIds: Iterable[ShortChannelId]): Unit = withMetrics("network/remove-channels", DbBackends.Sqlite) {
135-
using(sqlite.createStatement) { statement =>
135+
val batchSize = 100
136+
using(sqlite.prepareStatement(s"DELETE FROM channels WHERE short_channel_id IN (${List.fill(batchSize)("?").mkString(",")})")) { statement =>
136137
shortChannelIds
137-
.grouped(1000) // remove channels by batch of 1000
138-
.foreach { _ =>
139-
val ids = shortChannelIds.map(_.toLong).mkString(",")
140-
statement.executeUpdate(s"DELETE FROM channels WHERE short_channel_id IN ($ids)")
138+
.grouped(batchSize)
139+
.foreach { group =>
140+
val padded = group.toArray.padTo(batchSize, ShortChannelId(0L))
141+
for (i <- 0 until batchSize) {
142+
statement.setLong(1 + i, padded(i).toLong) // index for jdbc parameters starts at 1
143+
}
144+
statement.executeUpdate()
141145
}
142146
}
143147
}
@@ -153,8 +157,9 @@ class SqliteNetworkDb(sqlite: Connection) extends NetworkDb with Logging {
153157
}
154158

155159
override def removeFromPruned(shortChannelId: ShortChannelId): Unit = withMetrics("network/remove-from-pruned", DbBackends.Sqlite) {
156-
using(sqlite.createStatement) { statement =>
157-
statement.executeUpdate(s"DELETE FROM pruned WHERE short_channel_id=${shortChannelId.toLong}")
160+
using(sqlite.prepareStatement(s"DELETE FROM pruned WHERE short_channel_id=?")) { statement =>
161+
statement.setLong(1, shortChannelId.toLong)
162+
statement.executeUpdate()
158163
}
159164
}
160165

eclair-core/src/main/scala/fr/acinq/eclair/router/StaleChannels.scala

-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate}
2424
import fr.acinq.eclair.{ShortChannelId, TxCoordinates}
2525

2626
import scala.collection.mutable
27-
import scala.compat.Platform
2827
import scala.concurrent.duration._
2928

3029
object StaleChannels {

eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ object TestDatabases {
6868

6969
// @formatter:off
7070
override val connection: PgConnection = pg.getPostgresDatabase.getConnection.asInstanceOf[PgConnection]
71-
override lazy val db: Databases = Databases.PostgresDatabases(hikariConfig, UUID.randomUUID(), lock, jdbcUrlFile_opt = Some(jdbcUrlFile))
71+
override lazy val db: Databases = Databases.PostgresDatabases(hikariConfig, UUID.randomUUID(), lock, jdbcUrlFile_opt = Some(jdbcUrlFile), readOnlyUser_opt = None)
7272
override def getVersion(statement: Statement, db_name: String, currentVersion: Int): Int = PgUtils.getVersion(statement, db_name, currentVersion)
7373
override def close(): Unit = pg.close()
7474
// @formatter:on

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

+3-2
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ import fr.acinq.eclair.wire.protocol.{Color, NodeAddress, Tor2}
3030
import fr.acinq.eclair.{CltvExpiryDelta, Features, MilliSatoshiLong, ShortChannelId, TestDatabases, randomBytes32, randomKey}
3131
import org.scalatest.funsuite.AnyFunSuite
3232

33-
import scala.collection.{SortedMap, mutable}
33+
import scala.collection.{SortedMap, SortedSet, mutable}
34+
import scala.util.Random
3435

3536
class NetworkDbSpec extends AnyFunSuite {
3637

@@ -248,7 +249,7 @@ class NetworkDbSpec extends AnyFunSuite {
248249
updates.foreach(u => db.updateChannel(u))
249250
assert(db.listChannels().keySet === channels.map(_.shortChannelId).toSet)
250251

251-
val toDelete = channels.map(_.shortChannelId).drop(500).take(2500)
252+
val toDelete = channels.map(_.shortChannelId).take(1 + Random.nextInt(2500))
252253
db.removeChannels(toDelete)
253254
assert(db.listChannels().keySet === (channels.map(_.shortChannelId).toSet -- toDelete))
254255
}

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,19 @@ object PgUtilsSpec extends Logging {
9696
| port = $port
9797
| username = "postgres"
9898
| password = ""
99+
| readonly-user = ""
99100
| pool {
100101
| max-size = 10 // recommended value = number_of_cpu_cores * 2
101102
| connection-timeout = 30 seconds
102103
| idle-timeout = 10 minutes
103104
| max-life-time = 30 minutes
104105
| }
106+
| lock-type = "lease" // lease or none (do not use none in production)
105107
| lease {
106108
| interval = 5 seconds // lease-interval must be greater than lease-renew-interval
107109
| renew-interval = 2 seconds
110+
| lock-timeout = 5 seconds // timeout for the lock statement on the lease table
108111
| }
109-
| lock-type = "lease" // lease or none
110112
|}
111113
|""".stripMargin
112114
)

0 commit comments

Comments
 (0)