Skip to content

Commit 155c09a

Browse files
authored
Local postgresql with custom dynamic database support for development+CI (#9022)
1 parent 52475c1 commit 155c09a

File tree

5 files changed

+172
-10
lines changed

5 files changed

+172
-10
lines changed

.github/workflows/all-test.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ jobs:
9797
name: pkg/${{matrix.package}}/
9898
runs-on: ubuntu-latest
9999
needs: define_pkg_list
100+
services:
101+
postgres:
102+
image: postgres
103+
env:
104+
POSTGRES_PASSWORD: postgres
105+
# Set health checks to wait until postgres has started
106+
options: >-
107+
--health-cmd pg_isready
108+
--health-interval 10s
109+
--health-timeout 5s
110+
--health-retries 5
111+
ports:
112+
- 5432:5432
100113
strategy:
101114
fail-fast: false
102115
matrix:
@@ -137,3 +150,5 @@ jobs:
137150
fi
138151
dart test --run-skipped
139152
working-directory: pkg/${{matrix.package}}
153+
env:
154+
PUB_POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable

app/lib/database/database.dart

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:async';
6+
import 'dart:io';
7+
import 'dart:math';
8+
9+
import 'package:clock/clock.dart';
510
import 'package:gcloud/service_scope.dart' as ss;
611
import 'package:meta/meta.dart';
712
import 'package:postgres/postgres.dart';
813
import 'package:pub_dev/service/secret/backend.dart';
914
import 'package:pub_dev/shared/env_config.dart';
1015

16+
final _random = Random.secure();
17+
1118
/// Sets the primary database service.
1219
void registerPrimaryDatabase(PrimaryDatabase database) =>
1320
ss.register(#_primaryDatabase, database);
@@ -26,13 +33,38 @@ class PrimaryDatabase {
2633
/// the secret backend, connects to it and registers the primary database
2734
/// service in the current scope.
2835
static Future<void> tryRegisterInScope() async {
29-
final connectionString =
36+
var connectionString =
3037
envConfig.pubPostgresUrl ??
3138
(await secretBackend.lookup(SecretKey.postgresConnectionString));
32-
if (connectionString == null) {
39+
if (connectionString == null && envConfig.isRunningInAppengine) {
3340
// ignore for now, must throw once we have the environment setup ready
3441
return;
3542
}
43+
// The scope-specific custom database. We are creating a custom database for
44+
// each test run, in order to provide full isolation, however, this must not
45+
// be used in Appengine.
46+
String? customDb;
47+
if (connectionString == null) {
48+
(connectionString, customDb) = await _startOrUseLocalPostgresInDocker();
49+
}
50+
if (customDb == null && !envConfig.isRunningInAppengine) {
51+
customDb = await _createCustomDatabase(connectionString);
52+
}
53+
54+
if (customDb != null) {
55+
if (envConfig.isRunningInAppengine) {
56+
throw StateError('Should not use custom database inside AppEngine.');
57+
}
58+
59+
final originalUrl = connectionString;
60+
connectionString = Uri.parse(
61+
connectionString,
62+
).replace(path: customDb).toString();
63+
ss.registerScopeExitCallback(() async {
64+
await _dropCustomDatabase(originalUrl, customDb!);
65+
});
66+
}
67+
3668
final database = await _fromConnectionString(connectionString);
3769
registerPrimaryDatabase(database);
3870
ss.registerScopeExitCallback(database.close);
@@ -48,10 +80,64 @@ class PrimaryDatabase {
4880
}
4981

5082
@visibleForTesting
51-
Future<void> verifyConnection() async {
52-
final rs = await _pg.execute('SELECT 1');
83+
Future<String> verifyConnection() async {
84+
final rs = await _pg.execute('SELECT current_database();');
5385
if (rs.length != 1) {
5486
throw StateError('Connection is not returning expected rows.');
5587
}
88+
return rs.single.single as String;
89+
}
90+
}
91+
92+
Future<(String, String?)> _startOrUseLocalPostgresInDocker() async {
93+
// sanity check
94+
if (envConfig.isRunningInAppengine) {
95+
throw StateError('Missing connection URL in Appengine environment.');
96+
}
97+
98+
// the default connection URL for local server
99+
final url = Uri(
100+
scheme: 'postgresql',
101+
host: 'localhost',
102+
port: 55432,
103+
path: 'postgres',
104+
userInfo: 'postgres:postgres',
105+
queryParameters: {'sslmode': 'disable'},
106+
).toString();
107+
108+
try {
109+
// try opening the connection
110+
final customDb = await _createCustomDatabase(url);
111+
return (url, customDb);
112+
} catch (_) {
113+
// on failure start the local server
114+
final pr = await Process.run('tool/start-local-postgres.sh', []);
115+
if (pr.exitCode != 0) {
116+
throw StateError(
117+
'Unexpect exit code from tool/start-local-postgres.sh\n${pr.stderr}',
118+
);
119+
}
56120
}
121+
return (url, null);
122+
}
123+
124+
int _customDbCount = 0;
125+
126+
Future<String> _createCustomDatabase(String url) async {
127+
_customDbCount++;
128+
final dbName =
129+
'fake_pub_${pid.toRadixString(36)}'
130+
'${_customDbCount.toRadixString(36)}'
131+
'${clock.now().millisecondsSinceEpoch.toRadixString(36)}'
132+
'${_random.nextInt(1 << 32).toRadixString(36)}';
133+
final conn = await Connection.openFromUrl(url);
134+
await conn.execute('CREATE DATABASE "$dbName";');
135+
await conn.close(force: true);
136+
return dbName;
137+
}
138+
139+
Future<void> _dropCustomDatabase(String url, String dbName) async {
140+
final conn = await Connection.openFromUrl(url);
141+
await conn.execute('DROP DATABASE "$dbName";');
142+
await conn.close(force: true);
57143
}

app/test/database/postgresql_ci_test.dart

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,8 @@ void main() {
3939
testWithProfile(
4040
'registered database scope',
4141
fn: () async {
42-
final pubPostgresUrl = envConfig.pubPostgresUrl;
43-
if (pubPostgresUrl == null) {
44-
markTestSkipped('PUB_POSTGRES_URL was not specified.');
45-
return;
46-
}
47-
await primaryDatabase!.verifyConnection();
42+
final name = await primaryDatabase!.verifyConnection();
43+
expect(name, contains('fake_pub_'));
4844
},
4945
);
5046
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env bash
2+
3+
# Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
4+
# for details. All rights reserved. Use of this source code is governed by a
5+
# BSD-style license that can be found in the LICENSE file.
6+
7+
set -e
8+
9+
/usr/bin/timeout 6h /usr/local/bin/docker-entrypoint.sh $*

app/tool/start-local-postgres.sh

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env bash
2+
3+
# Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
4+
# for details. All rights reserved. Use of this source code is governed by a
5+
# BSD-style license that can be found in the LICENSE file.
6+
7+
set -e
8+
9+
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
10+
11+
PG_TEMP_DIR="${TMPDIR:-/tmp}/pub_dev_postgres/"
12+
mkdir -p "${PG_TEMP_DIR}"
13+
14+
# Create directory for exposing sockets
15+
SOCKET_DIR="${PG_TEMP_DIR}/run/"
16+
mkdir -p "$SOCKET_DIR"
17+
18+
# Use an extra lock file to avoid every creating more than one docker container
19+
LOCKFILE="${PG_TEMP_DIR}/.docker.lock"
20+
touch "${LOCKFILE}"
21+
22+
CONTAINER_ID=$((
23+
flock -ox 200
24+
if ! docker inspect 'pub_dev_postgres' > /dev/null 2>&1; then
25+
docker run \
26+
--detach \
27+
--rm \
28+
--name pub_dev_postgres \
29+
-e POSTGRES_PASSWORD=postgres \
30+
-v "${SCRIPT_DIR}/docker-postgres-timeout-entrypoint.sh":/pub-entrypoint.sh \
31+
-v "${SOCKET_DIR}":/var/run/postgresql/ \
32+
-p 55432:5432 \
33+
--mount type=tmpfs,destination=/var/lib/postgresql/data \
34+
--entrypoint /pub-entrypoint.sh \
35+
postgres:17 \
36+
postgres \
37+
-c fsync=off \
38+
-c synchronous_commit=off \
39+
-c full_page_writes=off \
40+
-c wal_level=minimal \
41+
-c max_wal_senders=0 \
42+
-c archive_mode=off
43+
fi
44+
) 200>"$LOCKFILE")
45+
46+
if [ -n "$CONTAINER_ID" ]; then
47+
if [ "$1" != '--quiet' ]; then
48+
echo 'Started postgres test database. Will auto-terminate in 6 hours.'
49+
fi
50+
else
51+
if [ "$1" != '--quiet' ]; then
52+
echo 'Found postgres test database already running!'
53+
echo 'If you want to restart it, you can use with:'
54+
echo 'docker kill pub_dev_postgres'
55+
fi
56+
fi

0 commit comments

Comments
 (0)