Skip to content

Commit 6337873

Browse files
committed
ci: share one Postgres sidecar across the integration-tests matrix
The release-gate matrix (ts/csharp/java/kotlin/python) had every port boot — and pull — its own Postgres container per scenario, which dominated CI time. Give the matrix job a single job-level `services: postgres` sidecar (mirroring the migrate-ts-pg job) and pass a shared admin URL via METAOBJECTS_TEST_PG_URL. Each port's PG helper (postgres-container.ts / PostgresContainer.{java,kt,cs} / postgres_container.py) now has two modes: - env var set (CI): connect to the shared sidecar and CREATE a uniquely-named database per scenario, DROP it on stop — preserving the per-container path's "fresh empty DB per scenario" isolation with no image pull / boot on the hot path. - env var unset (local dev): boot a per-scenario container exactly as before, so `scripts/integration-test.sh <port>` still works without the sidecar. Only where Postgres comes from changes; scenarios and assertions are untouched. Verified locally against a manually-started postgres:16 with the env var set: TS / Python / Java / C# / Kotlin QueryScenario suites all pass and leave no leftover databases; TS also passes with the env var unset (per-container fallback). Same 24-test TS suite: ~3.5s shared vs ~118s per-container boot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n
1 parent a8460a7 commit 6337873

6 files changed

Lines changed: 407 additions & 60 deletions

File tree

.github/workflows/integration-tests.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,28 @@ jobs:
2727
fail-fast: false
2828
matrix:
2929
port: [ts, csharp, java, kotlin, python]
30+
# A single job-level Postgres sidecar shared by every port, instead of each
31+
# port booting (and pulling) its own container per scenario. Mirrors the
32+
# migrate-ts-pg job below. Each port's PG helper, when it sees
33+
# METAOBJECTS_TEST_PG_URL, connects to this sidecar and CREATEs a
34+
# uniquely-named database per scenario (dropping it on stop) — preserving the
35+
# "fresh empty DB per scenario" isolation the per-port containers gave, with
36+
# no image pull / container boot on the hot path. Local dev (no env var) still
37+
# boots per-port containers exactly as before.
38+
services:
39+
postgres:
40+
image: postgres:16
41+
env:
42+
POSTGRES_USER: metaobjects
43+
POSTGRES_PASSWORD: metaobjects
44+
POSTGRES_DB: metaobjects_test
45+
ports:
46+
- 5432:5432
47+
options: >-
48+
--health-cmd "pg_isready -U metaobjects"
49+
--health-interval 10s
50+
--health-timeout 5s
51+
--health-retries 5
3052
steps:
3153
- uses: actions/checkout@v4
3254

@@ -82,6 +104,12 @@ jobs:
82104
run: bun install
83105

84106
- name: Run integration tests
107+
env:
108+
# The shared sidecar's admin URL. Each port's PG helper sees this and
109+
# creates/drops a uniquely-named database per scenario off it, rather
110+
# than booting its own container. Unset locally → per-port container
111+
# fallback.
112+
METAOBJECTS_TEST_PG_URL: postgres://metaobjects:metaobjects@localhost:5432/metaobjects_test
85113
run: ./scripts/integration-test.sh ${{ matrix.port }}
86114

87115
# migrate-ts PG integration tests — the apply / lifecycle / rollback +
Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,116 @@
1-
// PostgresContainer — a thin xUnit-friendly Testcontainers.PostgreSql wrapper.
2-
// Lifecycle: start fresh container per scenario (slower but maximally isolated;
3-
// shared-container with per-test schema namespacing is a future optimization).
1+
// PostgresContainer — obtain a fresh, isolated Postgres database per scenario.
2+
//
3+
// Two modes, mirroring the TS/Java/Kotlin/Python suites:
4+
// 1. Shared sidecar (CI): if METAOBJECTS_TEST_PG_URL is set, connect to that
5+
// already-running Postgres and CREATE a uniquely-named database per instance
6+
// (dropping it on dispose). No container boot / image pull on the hot path —
7+
// the point of the shared `services: postgres` CI sidecar. Each scenario
8+
// still gets a pristine empty database, so isolation is identical to the
9+
// per-container path.
10+
// 2. Per-container (local dev): with no env var, start a fresh
11+
// Testcontainers.PostgreSql container per scenario, exactly as before.
412

13+
using Npgsql;
514
using Testcontainers.PostgreSql;
615

716
namespace MetaObjects.IntegrationTests.Runner;
817

918
public sealed class PostgresContainer : IAsyncDisposable
1019
{
20+
// Env var naming the shared CI Postgres sidecar (admin URL). Unset = per-container fallback.
21+
private const string SharedPgUrlEnv = "METAOBJECTS_TEST_PG_URL";
22+
1123
public string ConnectionString { get; }
12-
private readonly PostgreSqlContainer _container;
24+
25+
private readonly PostgreSqlContainer? _container; // null in shared mode
26+
private readonly string? _adminConnString; // set in shared mode
27+
private readonly string? _createdDb; // set in shared mode
1328

1429
private PostgresContainer(PostgreSqlContainer container)
1530
{
1631
_container = container;
1732
ConnectionString = container.GetConnectionString();
1833
}
1934

35+
private PostgresContainer(string connectionString, string adminConnString, string createdDb)
36+
{
37+
ConnectionString = connectionString;
38+
_adminConnString = adminConnString;
39+
_createdDb = createdDb;
40+
}
41+
2042
public static async Task<PostgresContainer> StartAsync()
2143
{
44+
var sharedUrl = Environment.GetEnvironmentVariable(SharedPgUrlEnv);
45+
if (!string.IsNullOrWhiteSpace(sharedUrl))
46+
{
47+
return await StartOnSharedAsync(sharedUrl);
48+
}
49+
2250
var container = new PostgreSqlBuilder()
2351
.WithImage("postgres:16-alpine")
2452
.Build();
2553
await container.StartAsync();
2654
return new PostgresContainer(container);
2755
}
2856

29-
public ValueTask DisposeAsync() => _container.DisposeAsync();
57+
// Shared-sidecar mode: CREATE a fresh, uniquely-named database on the
58+
// already-running Postgres named by the URL, and return a connection string
59+
// pointing at it. A dedicated database per scenario preserves the per-
60+
// container path's "pristine empty DB" isolation.
61+
private static async Task<PostgresContainer> StartOnSharedAsync(string adminUrl)
62+
{
63+
var uri = new Uri(adminUrl);
64+
var userInfo = uri.UserInfo.Split(':', 2);
65+
var user = userInfo.Length > 0 ? Uri.UnescapeDataString(userInfo[0]) : "postgres";
66+
var password = userInfo.Length > 1 ? Uri.UnescapeDataString(userInfo[1]) : "";
67+
var port = uri.Port == -1 ? 5432 : uri.Port;
68+
var adminDb = uri.AbsolutePath.TrimStart('/');
69+
if (string.IsNullOrEmpty(adminDb)) adminDb = "postgres";
70+
71+
var adminBuilder = new NpgsqlConnectionStringBuilder
72+
{
73+
Host = uri.Host,
74+
Port = port,
75+
Username = user,
76+
Password = password,
77+
Database = adminDb,
78+
};
79+
var adminConnString = adminBuilder.ConnectionString;
80+
81+
var createdDb = "mo_test_cs_" + Guid.NewGuid().ToString("N");
82+
await using (var admin = new NpgsqlConnection(adminConnString))
83+
{
84+
await admin.OpenAsync();
85+
// Generated database name — no user input; safe to inline.
86+
await using var cmd = new NpgsqlCommand($"CREATE DATABASE \"{createdDb}\"", admin);
87+
await cmd.ExecuteNonQueryAsync();
88+
}
89+
90+
var scenarioBuilder = new NpgsqlConnectionStringBuilder(adminConnString) { Database = createdDb };
91+
return new PostgresContainer(scenarioBuilder.ConnectionString, adminConnString, createdDb);
92+
}
93+
94+
public async ValueTask DisposeAsync()
95+
{
96+
if (_container != null)
97+
{
98+
await _container.DisposeAsync();
99+
return;
100+
}
101+
102+
// Shared mode: drop the per-scenario database (best-effort).
103+
try
104+
{
105+
await using var admin = new NpgsqlConnection(_adminConnString);
106+
await admin.OpenAsync();
107+
await using var cmd = new NpgsqlCommand(
108+
$"DROP DATABASE IF EXISTS \"{_createdDb}\" WITH (FORCE)", admin);
109+
await cmd.ExecuteNonQueryAsync();
110+
}
111+
catch
112+
{
113+
// Best-effort cleanup.
114+
}
115+
}
30116
}

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/PostgresContainer.kt

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,86 @@ package com.metaobjects.integration.kotlin
33
import java.io.BufferedReader
44
import java.io.InputStreamReader
55
import java.net.ServerSocket
6+
import java.net.URI
67
import java.nio.charset.StandardCharsets
78
import java.sql.DriverManager
89
import java.util.UUID
910

1011
/**
11-
* Start a fresh Postgres container by shelling out to the `docker` CLI directly.
12+
* Obtain a fresh, isolated Postgres database per scenario.
1213
*
13-
* Implementation note: testcontainers-java 1.21.x bundles docker-java 3.4.x
14-
* which hardcodes a client API version (1.32-class) below the minimum
14+
* Two modes, mirroring the TS/Java/Python/C# suites:
15+
* 1. **Shared sidecar (CI)**: if `METAOBJECTS_TEST_PG_URL` is set, connect to
16+
* that already-running Postgres and `CREATE DATABASE` a uniquely-named
17+
* database per instance (dropping it on close). No container boot / image
18+
* pull on the hot path — the point of the shared `services: postgres` CI
19+
* sidecar. Each scenario still gets a pristine empty database, so isolation
20+
* is identical to the per-container path.
21+
* 2. **Per-container (local dev)**: with no env var, boot a fresh container via
22+
* the `docker` CLI, exactly as before.
23+
*
24+
* Implementation note (mode 2): testcontainers-java 1.21.x bundles docker-java
25+
* 3.4.x which hardcodes a client API version (1.32-class) below the minimum
1526
* supported by recent Docker daemons. The hand-managed CLI path here is small,
1627
* fast (~3s incl. pg_isready), and avoids the version-negotiation issue
1728
* entirely. Mirrors the Java port's `PostgresContainer.java` and the TS port's
18-
* `postgres-container.ts`.
29+
* `postgres-container.ts`. The shared sidecar mode sidesteps this entirely.
1930
*/
2031
class PostgresContainer : AutoCloseable {
21-
private val name: String = "metaobjects-test-kt-" + UUID.randomUUID().toString().substring(0, 8)
22-
private val port: Int = pickFreePort()
32+
private val shared: Boolean
33+
private val name: String? // null in shared mode
34+
private val adminUrl: String? // JDBC admin URL, null in per-container mode
35+
private val createdDb: String? // created database name, null in per-container mode
2336
val jdbcUrl: String
24-
25-
val username: String = PG_USER
26-
val password: String = PG_PASSWORD
37+
val username: String
38+
val password: String
2739

2840
init {
29-
runDocker(
30-
"run", "-d", "--rm",
31-
"--name", name,
32-
"-e", "POSTGRES_PASSWORD=$PG_PASSWORD",
33-
"-p", "$port:5432",
34-
IMAGE,
35-
)
36-
jdbcUrl = "jdbc:postgresql://localhost:$port/postgres"
37-
waitForReady()
41+
val sharedUri = System.getenv(SHARED_PG_URL_ENV)
42+
if (!sharedUri.isNullOrBlank()) {
43+
shared = true
44+
name = null
45+
val u = URI.create(sharedUri)
46+
val userInfo = (u.userInfo ?: "").split(":", limit = 2)
47+
username = userInfo.getOrElse(0) { PG_USER }
48+
password = userInfo.getOrElse(1) { "" }
49+
val port = if (u.port == -1) 5432 else u.port
50+
val adminDb = if (u.path.isNullOrEmpty() || u.path.length <= 1) "postgres" else u.path.substring(1)
51+
adminUrl = "jdbc:postgresql://${u.host}:$port/$adminDb"
52+
createdDb = "mo_test_kt_" + UUID.randomUUID().toString().replace("-", "")
53+
execAdmin("CREATE DATABASE \"$createdDb\"") // generated name — no user input
54+
jdbcUrl = "jdbc:postgresql://${u.host}:$port/$createdDb"
55+
} else {
56+
shared = false
57+
adminUrl = null
58+
createdDb = null
59+
username = PG_USER
60+
password = PG_PASSWORD
61+
name = "metaobjects-test-kt-" + UUID.randomUUID().toString().substring(0, 8)
62+
val port = pickFreePort()
63+
runDocker(
64+
"run", "-d", "--rm",
65+
"--name", name,
66+
"-e", "POSTGRES_PASSWORD=$PG_PASSWORD",
67+
"-p", "$port:5432",
68+
IMAGE,
69+
)
70+
jdbcUrl = "jdbc:postgresql://localhost:$port/postgres"
71+
waitForReady()
72+
}
3873
}
3974

4075
override fun close() {
76+
if (shared) {
77+
try {
78+
execAdmin("DROP DATABASE IF EXISTS \"$createdDb\" WITH (FORCE)")
79+
} catch (_: RuntimeException) {
80+
// Best-effort cleanup.
81+
}
82+
return
83+
}
4184
try {
42-
runDocker("rm", "-f", name)
85+
runDocker("rm", "-f", name!!) // non-null in per-container mode
4386
} catch (_: RuntimeException) {
4487
// Container may already be gone; ignore.
4588
}
@@ -49,6 +92,13 @@ class PostgresContainer : AutoCloseable {
4992
// Helpers
5093
// -----------------------------------------------------------------------
5194

95+
/** Run a statement against the shared sidecar's admin database (auto-commit). */
96+
private fun execAdmin(sql: String) {
97+
DriverManager.getConnection(adminUrl, username, password).use { c ->
98+
c.createStatement().use { s -> s.execute(sql) }
99+
}
100+
}
101+
52102
private fun waitForReady() {
53103
val deadline = System.currentTimeMillis() + 30_000
54104
while (System.currentTimeMillis() < deadline) {
@@ -77,6 +127,8 @@ class PostgresContainer : AutoCloseable {
77127
}
78128

79129
companion object {
130+
/** Env var naming the shared CI Postgres sidecar (admin URL). Unset = per-container fallback. */
131+
private const val SHARED_PG_URL_ENV = "METAOBJECTS_TEST_PG_URL"
80132
private const val IMAGE = "postgres:16-alpine"
81133
private const val PG_USER = "postgres"
82134
private const val PG_PASSWORD = "test"

0 commit comments

Comments
 (0)