Skip to content

Commit b828467

Browse files
fix(ci): bound queue DB connections to stop E2E 1040 stalls
## Summary ### Why? Tenant-scoped reconciliation fans out one goroutine per tenant per subscription tick. With `database/sql`'s unlimited open-connection default, the E2E stack opened more sockets than MySQL's ~151-connection ceiling, returned Error 1040, and stalled pipeline progress until Bazel timed out. Gateway integration also published logs under a tenant that was not in `MQ_TENANTS`, so the consumer never saw them. ### What? - Apply `QUEUE_MYSQL_MAX_OPEN_CONNECTIONS` (default 16) on each service-owned queue `*sql.DB`, and keep that many connections idle so the cap does not churn sockets. - Point the gateway log-consumer integration at the configured `test-queue` tenant and scope the land-message lookup by tenant. - Upload Bazel `test.log` files on failure for every CI test job, with `actions: write` on the workflow token so `upload-artifact` can run. ## Test Plan ✅ `./tool/bazel test //service/messagequeue:go_default_test` ✅ `./tool/bazel test //test/integration/submitqueue/gateway:go_default_test --sandbox_writable_path="$HOME/.docker/buildx"` ✅ `./tool/bazel test //test/e2e/submitqueue:go_default_test --sandbox_writable_path="$HOME/.docker/buildx"` (167s, then 161s) ✅ `./tool/bazel test //test/e2e/submitqueue:go_default_test --test_filter='TestGitMergeE2E' --sandbox_writable_path="$HOME/.docker/buildx"` ✅ `make fmt && make gazelle && make tidy` Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c4cd74e commit b828467

16 files changed

Lines changed: 178 additions & 23 deletions

File tree

‎.github/actions/run-bazel-test/action.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ runs:
1717
env:
1818
TARGET: ${{ inputs.target }}
1919
run: ./tool/bazel test "$TARGET" --test_output=streamed
20+
21+
- name: Upload Bazel failure logs
22+
if: ${{ failure() }}
23+
uses: ./.github/actions/upload-testlogs
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: Upload Bazel test logs
2+
description: Upload bazel-testlogs on job failure so truncated CI output is not the only diagnostic.
3+
4+
runs:
5+
using: composite
6+
steps:
7+
- name: Upload Bazel failure logs
8+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
9+
with:
10+
name: testlogs-${{ github.job }}-${{ github.run_id }}-${{ github.run_attempt }}
11+
path: bazel-testlogs/**/test.log
12+
if-no-files-found: warn
13+
retention-days: 7

‎.github/workflows/ci.yml‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ on:
1919

2020
permissions:
2121
contents: read
22+
actions: write
2223

2324
# Cancel superseded runs for the same PR to save runner minutes. Never cancel
2425
# in-progress runs for `push` (main) or `merge_group` — those must complete so
@@ -88,6 +89,10 @@ jobs:
8889
- name: Run unit tests
8990
run: make test
9091

92+
- name: Upload Bazel failure logs
93+
if: ${{ failure() }}
94+
uses: ./.github/actions/upload-testlogs
95+
9196
# ---------------------------------------------------------------------------
9297
# INTEGRATION TESTS (e2e, gateway, orchestrator)
9398
# ---------------------------------------------------------------------------
@@ -106,6 +111,10 @@ jobs:
106111
- name: Run E2E tests
107112
run: make e2e-test
108113

114+
- name: Upload Bazel failure logs
115+
if: ${{ failure() }}
116+
uses: ./.github/actions/upload-testlogs
117+
109118
gateway-integration-test:
110119
name: Gateway Integration Test
111120
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
@@ -121,6 +130,10 @@ jobs:
121130
- name: Run Gateway integration tests
122131
run: make integration-test-submitqueue-gateway
123132

133+
- name: Upload Bazel failure logs
134+
if: ${{ failure() }}
135+
uses: ./.github/actions/upload-testlogs
136+
124137
orchestrator-integration-test:
125138
name: Orchestrator Integration Test
126139
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
@@ -136,6 +149,10 @@ jobs:
136149
- name: Run Orchestrator integration tests
137150
run: make integration-test-submitqueue-orchestrator
138151

152+
- name: Upload Bazel failure logs
153+
if: ${{ failure() }}
154+
uses: ./.github/actions/upload-testlogs
155+
139156
# ---------------------------------------------------------------------------
140157
# EXTENSION TESTS
141158
# ---------------------------------------------------------------------------
@@ -243,7 +260,8 @@ jobs:
243260
# malicious actor controlling ci.yml could delete the guard itself, so the
244261
# real defenses remain (a) secrets scoped to a main-only Environment so a
245262
# PR-triggered job cannot obtain them, and (b) CODEOWNERS review on
246-
# .github/. GITHUB_TOKEN (least-privilege, read-only here) is allowlisted.
263+
# .github/. GITHUB_TOKEN is scoped to contents: read and actions: write
264+
# (artifact upload on failed test jobs) and is allowlisted.
247265
- name: Guard — no repository secrets on the untrusted-code path
248266
run: |
249267
hits="$(grep -rnE '\$\{\{[^}]*secrets\.' \

‎service/messagequeue/BUILD.bazel‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@ load("@rules_go//go:def.bzl", "go_library", "go_test")
22

33
go_library(
44
name = "go_default_library",
5-
srcs = ["tenant.go"],
5+
srcs = [
6+
"database.go",
7+
"tenant.go",
8+
],
69
importpath = "github.com/uber/submitqueue/service/messagequeue",
710
visibility = ["//visibility:public"],
811
)
912

1013
go_test(
1114
name = "go_default_test",
12-
srcs = ["tenant_test.go"],
15+
srcs = [
16+
"database_test.go",
17+
"tenant_test.go",
18+
],
1319
embed = [":go_default_library"],
1420
deps = [
21+
"@com_github_go_sql_driver_mysql//:go_default_library",
1522
"@com_github_stretchr_testify//assert:go_default_library",
1623
"@com_github_stretchr_testify//require:go_default_library",
1724
],

‎service/messagequeue/database.go‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package messagequeue
16+
17+
import (
18+
"database/sql"
19+
"fmt"
20+
"strconv"
21+
"strings"
22+
)
23+
24+
// DefaultQueueMySQLMaxOpenConnections bounds queue database concurrency when
25+
// the service does not provide an explicit limit.
26+
const DefaultQueueMySQLMaxOpenConnections = 16
27+
28+
// ConfigureQueueMySQLConnectionPool applies the configured maximum open
29+
// connection count and keeps that many connections idle. Empty input
30+
// selects DefaultQueueMySQLMaxOpenConnections.
31+
func ConfigureQueueMySQLConnectionPool(db *sql.DB, value string) error {
32+
maxOpenConnections := DefaultQueueMySQLMaxOpenConnections
33+
trimmed := strings.TrimSpace(value)
34+
if trimmed != "" {
35+
parsed, err := strconv.Atoi(trimmed)
36+
if err != nil || parsed <= 0 {
37+
return fmt.Errorf("maximum open connections must be a positive integer")
38+
}
39+
maxOpenConnections = parsed
40+
}
41+
db.SetMaxOpenConns(maxOpenConnections)
42+
db.SetMaxIdleConns(maxOpenConnections)
43+
return nil
44+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package messagequeue
16+
17+
import (
18+
"database/sql"
19+
"testing"
20+
21+
_ "github.com/go-sql-driver/mysql"
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
)
25+
26+
func TestConfigureQueueMySQLConnectionPool(t *testing.T) {
27+
tests := []struct {
28+
name string
29+
value string
30+
want int
31+
wantErr bool
32+
}{
33+
{name: "empty uses default", want: DefaultQueueMySQLMaxOpenConnections},
34+
{name: "explicit value", value: "24", want: 24},
35+
{name: "surrounding whitespace", value: " 8 ", want: 8},
36+
{name: "zero", value: "0", wantErr: true},
37+
{name: "negative", value: "-1", wantErr: true},
38+
{name: "non-integer", value: "many", wantErr: true},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
db, err := sql.Open("mysql", "")
44+
require.NoError(t, err)
45+
t.Cleanup(func() { require.NoError(t, db.Close()) })
46+
47+
err = ConfigureQueueMySQLConnectionPool(db, tt.value)
48+
if tt.wantErr {
49+
require.Error(t, err)
50+
return
51+
}
52+
require.NoError(t, err)
53+
assert.Equal(t, tt.want, db.Stats().MaxOpenConnections)
54+
})
55+
}
56+
}

‎service/runway/server/docker-compose.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ services:
5050
- MERGER=${SQ_RUNWAY_MERGER:-}
5151
# Queue infrastructure connection
5252
- QUEUE_MYSQL_DSN=root:root@tcp(mysql-queue:3306)/submitqueue?parseTime=true
53+
- QUEUE_MYSQL_MAX_OPEN_CONNECTIONS=${QUEUE_MYSQL_MAX_OPEN_CONNECTIONS:-16}
5354
# Level for the queue's own logs; info by default so its per-message
5455
# chatter does not bury the rest of the service at debug.
5556
- QUEUE_LOG_LEVEL=${QUEUE_LOG_LEVEL:-}

‎service/runway/server/main.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ func run() error {
140140
return fmt.Errorf("failed to open queue database: %w", err)
141141
}
142142
defer queueDB.Close()
143+
if err := servicemq.ConfigureQueueMySQLConnectionPool(queueDB, os.Getenv("QUEUE_MYSQL_MAX_OPEN_CONNECTIONS")); err != nil {
144+
return fmt.Errorf("failed to configure queue database pool: %w", err)
145+
}
143146

144147
tenants, err := servicemq.ParseRequiredTenants(os.Getenv("MQ_TENANTS"))
145148
if err != nil {

‎service/stovepipe/docker-compose.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ services:
6767
- PORT=:8080
6868
- STORAGE_MYSQL_DSN=root:root@tcp(mysql-app:3306)/submitqueue?parseTime=true
6969
- QUEUE_MYSQL_DSN=root:root@tcp(mysql-queue:3306)/submitqueue?parseTime=true
70+
- QUEUE_MYSQL_MAX_OPEN_CONNECTIONS=${QUEUE_MYSQL_MAX_OPEN_CONNECTIONS:-16}
7071
# Level for the queue's own logs; info by default so its per-message
7172
# chatter does not bury the rest of the service at debug.
7273
- QUEUE_LOG_LEVEL=${QUEUE_LOG_LEVEL:-}

‎service/stovepipe/server/main.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,9 @@ func run() error {
273273
return fmt.Errorf("failed to open queue database: %w", err)
274274
}
275275
defer queueDB.Close()
276+
if err := servicemq.ConfigureQueueMySQLConnectionPool(queueDB, os.Getenv("QUEUE_MYSQL_MAX_OPEN_CONNECTIONS")); err != nil {
277+
return fmt.Errorf("failed to configure queue database pool: %w", err)
278+
}
276279

277280
tenants, err := servicemq.ParseRequiredTenants(os.Getenv("MQ_TENANTS"))
278281
if err != nil {

0 commit comments

Comments
 (0)