postgresql-testing: "Lock" ports when starting PostgreSQL. (#5310)

* http-json: Ask for a free port by specifying port 0.

This will avoid race conditions.

* bindings-akka-testing: Delete RandomPorts; it's unused.

* ports: Fix the Bazel test glob.

* ports: Move FreePort to postgresql-testing and add a test case.

* postgresql-testing: Make `FreePort.find()` return a `Port`.

* postgresql-testing: Lock free ports until the server starts.

This uses a `FileLock`, which should work well on all our
supported operating systems as long as everyone agrees to use it.

CHANGELOG_BEGIN
CHANGELOG_END

* postgresql-testing: Try to find a free port 10 times, then give up.

* postgresql-testing: Use a shared directory for the port lock.

* postgresql-testing: Try an alternative way of getting `%LOCALAPPDATA%`.
This commit is contained in:
Samir Talwar 2020-03-31 19:13:02 +02:00 committed by GitHub
parent 819210827e
commit 52dbcf5d95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 177 additions and 69 deletions

View File

@ -20,13 +20,11 @@ da_scala_library(
],
deps = [
"//ledger-api/rs-grpc-bridge",
"//libs-scala/ports",
"@maven//:com_typesafe_akka_akka_actor_2_12",
"@maven//:com_typesafe_akka_akka_stream_2_12",
"@maven//:com_typesafe_config",
"@maven//:com_typesafe_scala_logging_scala_logging_2_12",
"@maven//:org_scalactic_scalactic_2_12",
"@maven//:org_scalatest_scalatest_2_12",
"@maven//:org_slf4j_slf4j_api",
],
)

View File

@ -1,35 +0,0 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.ledger.client.testing
import com.digitalasset.ports.FreePort
import com.typesafe.scalalogging.LazyLogging
import scala.annotation.tailrec
trait RandomPorts extends LazyLogging { self: AkkaTest =>
def randomPort(lowerBound: Option[Int] = None, upperBound: Option[Int] = None): Int = {
@tailrec
def tryNext(candidatePort: Int = 0): Int = {
if (candidatePort != 0) {
logger.info(s"Using port $candidatePort")
candidatePort
} else {
val port = FreePort.find()
logger.info(s"Checking port $port")
val isHighEnough = lowerBound.fold(true)(port > _)
val isLowEnough = upperBound.fold(true)(port < _)
if (isHighEnough && isLowEnough) {
tryNext(port)
} else {
tryNext()
}
}
}
tryNext()
}
}

View File

@ -27,7 +27,7 @@ import com.digitalasset.platform.common.LedgerIdMode
import com.digitalasset.platform.sandbox.SandboxServer
import com.digitalasset.platform.sandbox.config.SandboxConfig
import com.digitalasset.platform.services.time.TimeProviderType
import com.digitalasset.ports.{FreePort, Port}
import com.digitalasset.ports.Port
import scalaz._
import scalaz.std.option._
import scalaz.std.scalaFuture._
@ -62,23 +62,22 @@ object HttpServiceTestFixture {
port <- ledger.portF
} yield (ledger, port)
val httpServiceF: Future[(ServerBinding, Int)] = for {
val httpServiceF: Future[ServerBinding] = for {
(_, ledgerPort) <- ledgerF
contractDao <- contractDaoF
httpPort <- Future(FreePort.find())
httpService <- stripLeft(
HttpService.start(
"localhost",
ledgerPort.value,
applicationId,
"localhost",
httpPort,
0,
Some(Config.DefaultWsConfig),
None,
contractDao,
staticContentConfig,
doNotReloadPackages))
} yield (httpService, httpPort)
} yield httpService
val clientF: Future[LedgerClient] = for {
(_, ledgerPort) <- ledgerF
@ -91,10 +90,11 @@ object HttpServiceTestFixture {
} yield codecs
val fa: Future[A] = for {
(_, httpPort) <- httpServiceF
httpService <- httpServiceF
address = httpService.localAddress
uri = Uri.from(scheme = "http", host = address.getHostName, port = address.getPort)
(encoder, decoder) <- codecsF
client <- clientF
uri = Uri.from(scheme = "http", host = "localhost", port = httpPort)
a <- testFn(uri, encoder, decoder, client)
} yield a
@ -103,7 +103,7 @@ object HttpServiceTestFixture {
.sequence(
Seq(
ledgerF.map(_._1.close()),
httpServiceF.flatMap(_._1.unbind()),
httpServiceF.flatMap(_.unbind()),
) map (_ fallbackTo Future.successful(())))
.transform(_ => ta)
}

View File

@ -18,7 +18,7 @@ da_scala_library(
da_scala_test(
name = "ports-tests",
srcs = glob(["src/test/suite/**/*.scala"]),
srcs = glob(["src/test/suite/scala/**/*.scala"]),
deps = [
":ports",
],

View File

@ -1,20 +0,0 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.ports
import java.net.{InetAddress, ServerSocket}
object FreePort {
def find(): Int = {
val socket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress)
try {
socket.getLocalPort
} finally {
// We have to release the port so that it can be used. Note that there is a small race window,
// as releasing the port then handing it to the server is not atomic. If this turns out to be
// an issue, we need to find an atomic way of doing that.
socket.close()
}
}
}

View File

@ -4,6 +4,7 @@
load(
"//bazel_tools:scala.bzl",
"da_scala_library",
"da_scala_test",
)
da_scala_library(
@ -25,3 +26,12 @@ da_scala_library(
"@maven//:org_scalatest_scalatest_2_12",
],
)
da_scala_test(
name = "postgresql-testing-tests",
srcs = glob(["src/test/suite/scala/**/*.scala"]),
deps = [
":postgresql-testing",
"//libs-scala/ports",
],
)

View File

@ -0,0 +1,37 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.testing.postgresql
import java.net.{InetAddress, ServerSocket}
import com.digitalasset.ports.Port
import scala.annotation.tailrec
private[postgresql] object FreePort {
@tailrec
def find(tries: Int = 10): PortLock.Locked = {
val socket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress)
val portLock = try {
val port = Port(socket.getLocalPort)
PortLock.lock(port)
} finally {
socket.close()
}
portLock match {
case Right(locked) =>
socket.close()
locked
case Left(failure) =>
socket.close()
if (tries <= 1) {
throw failure
} else {
find(tries - 1)
}
}
}
}

View File

@ -0,0 +1,68 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.testing.postgresql
import java.io.RandomAccessFile
import java.nio.channels.{
ClosedChannelException,
FileChannel,
FileLock,
OverlappingFileLockException
}
import java.nio.file.{Files, Path, Paths}
import com.digitalasset.ports.Port
private[postgresql] object PortLock {
// We can't use `sys.props("java.io.tmpdir")` because Bazel changes this for each test run.
// For this to be useful, it needs to be shared across concurrent runs.
private val portLockDirectory: Path = {
val tempDirectory =
if (sys.props("os.name").startsWith("Windows")) {
Paths.get(sys.props("user.home"), "AppData", "Local", "Temp")
} else {
Paths.get("/tmp")
}
tempDirectory.resolve(Paths.get("daml", "build", "postgresql-testing", "ports"))
}
def lock(port: Port): Either[FailedToLock, Locked] = {
Files.createDirectories(portLockDirectory)
val portLockFile = portLockDirectory.resolve(port.toString)
val file = new RandomAccessFile(portLockFile.toFile, "rw")
val channel = file.getChannel
try {
val lock = channel.tryLock()
val locked = new Locked(port, lock, channel, file)
if (lock != null) {
Right(locked)
} else {
locked.unlock()
Left(FailedToLock(port))
}
} catch {
case _: OverlappingFileLockException =>
channel.close()
file.close()
Left(FailedToLock(port))
}
}
final class Locked(val port: Port, lock: FileLock, channel: FileChannel, file: RandomAccessFile) {
def unlock(): Unit = {
try {
lock.release()
} catch {
// ignore
case _: ClosedChannelException =>
}
channel.close()
file.close()
}
}
case class FailedToLock(port: Port) extends RuntimeException(s"Failed to lock port $port.")
}

View File

@ -9,7 +9,6 @@ import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Path, Paths}
import java.util.concurrent.atomic.AtomicBoolean
import com.digitalasset.ports.FreePort
import com.digitalasset.testing.postgresql.PostgresAround._
import org.apache.commons.io.{FileUtils, IOUtils}
import org.slf4j.LoggerFactory
@ -28,7 +27,8 @@ trait PostgresAround {
val tempDir = Files.createTempDirectory("postgres_test")
val dataDir = tempDir.resolve("data")
val confFile = Paths.get(dataDir.toString, "postgresql.conf")
val port = FreePort.find()
val lockedPort = FreePort.find()
val port = lockedPort.port
val jdbcUrl = s"jdbc:postgresql://$hostName:$port/$databaseName?user=$userName"
val logFile = Files.createFile(tempDir.resolve("postgresql.log"))
postgresFixture = PostgresFixture(jdbcUrl, port, tempDir, dataDir, confFile, logFile)
@ -37,10 +37,12 @@ trait PostgresAround {
initializeDatabase()
createConfigFile()
startPostgres()
lockedPort.unlock()
createTestDatabase(databaseName)
logger.info(s"PostgreSQL has started on port $port.")
} catch {
case NonFatal(e) =>
lockedPort.unlock()
stopPostgres()
deleteRecursively(tempDir)
postgresFixture = null

View File

@ -5,9 +5,11 @@ package com.digitalasset.testing.postgresql
import java.nio.file.Path
import com.digitalasset.ports.Port
case class PostgresFixture(
jdbcUrl: String,
port: Int,
port: Port,
tempDir: Path,
dataDir: Path,
confFile: Path,

View File

@ -0,0 +1,46 @@
// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package com.digitalasset.testing.postgresql
import org.scalatest.{Matchers, WordSpec}
class FreePortSpec extends WordSpec with Matchers {
"a free port" should {
"always be available" in {
val lockedPort = FreePort.find()
try {
lockedPort.port.value should (be >= 1024 and be < 65536)
} finally {
lockedPort.unlock()
}
}
"lock, to prevent race conditions" in {
val lockedPort = FreePort.find()
try {
PortLock.lock(lockedPort.port) should be(Left(PortLock.FailedToLock(lockedPort.port)))
} finally {
lockedPort.unlock()
}
}
"unlock when the server's started" in {
val lockedPort = FreePort.find()
lockedPort.unlock()
val locked = PortLock
.lock(lockedPort.port)
.fold(failure => throw failure, identity)
locked.unlock()
succeed
}
"can be unlocked twice" in {
val lockedPort = FreePort.find()
lockedPort.unlock()
lockedPort.unlock()
succeed
}
}
}