Skip to content

Commit f21456b

Browse files
authored
Run mixed mode tests in a workflow, and publish results to release notes (#3166)
This adds a workflow to run the yaml-tests at a given tag against the last 50 releases, and adds the results to `docs/ReleaseNotes.md`. Note: Currently it will only download back to `4.0.559.1`, because older builds didn't have a server to do mixed-mode testing against. Currently this is a manual workflow that someone would run after a successful build, but once proven, we can change it to automatically run after a release. There is some discussion in @alecgrieser's review, but I will also point out here that the results are somewhat weakened by the use of `!supported_version`. For example, `prepared.yamsql` is set to `!supported_version: 4.1.6.0` because that support only exists in `4.1.6.0`, but it was also added to `4.0.559.6` (which was released after the test run in the sample below). We also have issues where the behavior may change between two versions. None of that is visible here, and would have to be noted in the release notes, and found there. But hopefully the fact that we run single version mixed-mode tests during PRB and release will prevent issues from completely slipping through. Sample output (an actual run, but the link is to a different workflow): > #### Mixed Mode Test Results > > Mixed mode testing run against the following previous versions: > ✅`4.0.559.1`, ✅`4.0.559.2`, ✅`4.0.559.3`, ✅`4.0.559.4`, ❌`4.0.561.0`, ✅`4.0.562.0`, ✅`4.0.564.0`, ✅`4.0.565.0`, ✅`4.0.566.0`, ✅`4.0.567.0`, ✅`4.0.568.0`, ✅`4.0.569.0`, ✅`4.0.570.0`, ✅`4.0.571.0`, ✅`4.0.572.0`, ✅`4.0.573.0`, ✅`4.0.574.0`, ✅`4.0.575.0`, ✅`4.1.4.0`, ✅`4.1.5.0`, ✅`4.1.6.0` > > [See full test run](https://github.com/FoundationDB/fdb-record-layer/actions/runs/13331380671)
1 parent 879c18a commit f21456b

File tree

8 files changed

+280
-9
lines changed

8 files changed

+280
-9
lines changed

.github/workflows/mixed_mode_test.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Mixed Mode Test
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
tag:
7+
description: 'Tag to test from'
8+
required: true
9+
10+
jobs:
11+
gradle:
12+
runs-on: ubuntu-latest
13+
permissions:
14+
pull-requests: write
15+
steps:
16+
- name: Checkout sources
17+
uses: actions/[email protected]
18+
with:
19+
ref: ${{ inputs.tag }}
20+
ssh-key: ${{ secrets.DEPLOY_KEY }}
21+
- name: Fetch Main
22+
run: git fetch --depth=1 origin main
23+
- name: Setup Base Environment
24+
uses: ./actions/setup-base-env
25+
- name: Setup FDB
26+
uses: ./actions/setup-fdb
27+
28+
# Push a version bump back to main. There are failure scenarios that can result
29+
# in published artifacts but an erroneous build, so it's safer to bump the version
30+
# at the beginning
31+
- name: Configure git
32+
run: |
33+
git config --global user.name 'FoundationDB CI'
34+
git config --global user.email '[email protected]'
35+
- name: Run Gradle Test
36+
uses: ./actions/gradle-test
37+
with:
38+
gradle_command: mixedModeTest
39+
gradle_args: -PreleaseBuild=false -PpublishBuild=false
40+
- name: Checkout Main
41+
run: git checkout main
42+
- name: Update release notes
43+
run: python build/publish-mixed-mode-results.py ${{ inputs.tag }} --release-notes docs/ReleaseNotes.md --commit --run-link ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
44+
- name: Push release notes update
45+
run: git push
46+

build/publish-mixed-mode-results.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env python3
2+
3+
#
4+
# publish_mixed_mode_results.py
5+
#
6+
# This source file is part of the FoundationDB open source project
7+
#
8+
# Copyright 2015-2025 Apple Inc. and the FoundationDB project authors
9+
#
10+
# Licensed under the Apache License, Version 2.0 (the "License");
11+
# you may not use this file except in compliance with the License.
12+
# You may obtain a copy of the License at
13+
#
14+
# http://www.apache.org/licenses/LICENSE-2.0
15+
#
16+
# Unless required by applicable law or agreed to in writing, software
17+
# distributed under the License is distributed on an "AS IS" BASIS,
18+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19+
# See the License for the specific language governing permissions and
20+
# limitations under the License.
21+
#
22+
23+
# This script generates a list of mixed-mode testing results generated by the task
24+
# ./gradlew mixedModeTest
25+
26+
import argparse
27+
import subprocess
28+
import sys
29+
30+
def run(command):
31+
try:
32+
process = subprocess.run(command, check=True, capture_output=True, text=True)
33+
return process.stdout
34+
except subprocess.CalledProcessError as e:
35+
print("Failed: " + str(e.cmd))
36+
print(e.stdout)
37+
print(e.stderr)
38+
exit(e.returncode)
39+
40+
def get_results(results_path):
41+
results = {}
42+
with open(results_path) as f:
43+
for line in f:
44+
split = line.strip().split(' ')
45+
if len(split) != 2:
46+
raise Exception("Line is not valid: " + line)
47+
result = split[0]
48+
version = split[1]
49+
if result == 'FAILURE':
50+
results[version] = result
51+
elif result == 'SUCCESS' and version not in results:
52+
results[version] = result
53+
return results
54+
55+
def emoji(result_word):
56+
if result_word == 'FAILURE':
57+
return '❌'
58+
elif result_word == 'SUCCESS':
59+
return '✅'
60+
else:
61+
raise Exception('Invalid result type: ' + result_word)
62+
63+
def generate_markdown(version, results, header_size):
64+
sorted_keys = sorted(results.keys(), key=lambda raw: [int(part) for part in raw.split('.')])
65+
66+
return header_size + " Mixed Mode Test Results\n\nMixed mode testing run against the following previous versions:\n\n" + \
67+
', '.join([emoji(results[version]) + '`' + version + '`' for version in sorted_keys])
68+
69+
def update_release_notes_file(markdown, version, filename):
70+
with open(filename, 'r') as fin:
71+
lines = fin.read().split('\n')
72+
i = 0
73+
target = '<!-- MIXED_MODE_RESULTS ' + version + ' PLACEHOLDER -->'
74+
while i < len(lines) and not lines[i].startswith(target):
75+
i+= 1
76+
if i == len(lines):
77+
raise Exception('Could not find placeholder in release notes file')
78+
return '\n'.join(lines[:i]
79+
+ [markdown]
80+
+ lines[i+1:])
81+
82+
def commit_updates(filename, version):
83+
subprocess.run(['git', 'commit', '-m', "Recording " + version + "mixed mode test results in release notes", filename],
84+
check=True)
85+
86+
def main(argv):
87+
'''Process the output of a mixedModeTest run and convert it into a short markdown'''
88+
parser = argparse.ArgumentParser()
89+
parser.add_argument('--results-path', help='Path to the results', default='.out/reports/mixed-mode-results.log')
90+
parser.add_argument('--release-notes', help='If provided, the results will be injected into this file')
91+
parser.add_argument('--header-size', help='Markdown header level (e.g. # or ##)', default='####')
92+
parser.add_argument('--run-link', help='A link to the test run that generated the results')
93+
parser.add_argument('--commit', action='store_true', default=False, help='Commit the updates to the release notes')
94+
parser.add_argument('version', help='Version of the server that was tested')
95+
args = parser.parse_args(argv)
96+
97+
markdown = generate_markdown(args.version, get_results(args.results_path), args.header_size)
98+
if args.run_link is not None:
99+
markdown = markdown + "\n\n[See full test run](" + args.run_link +")"
100+
if args.release_notes is None:
101+
print(markdown)
102+
else:
103+
new_content = update_release_notes_file(markdown, args.version, args.release_notes)
104+
with open(args.release_notes, 'w') as fout:
105+
fout.write(new_content)
106+
if args.commit:
107+
commit_updates(args.release_notes, args.version)
108+
print(f'Updated {args.release_notes} with test results for {args.version}')
109+
110+
if __name__ == '__main__':
111+
main(sys.argv[1:])

build/update_release_notes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ def get_new_contents(filename, new_version):
106106
+ [template]
107107
+ ['// end next release', '-->', '']
108108
+ updated_next_release_notes
109+
+ ['', '<!-- MIXED_MODE_RESULTS ' + new_version + ' PLACEHOLDER -->', '']
109110
+ lines[next_release_end+3:])
110111

111112

docs/ReleaseNotes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ e** Change 1 [(Issue #NNN)](https://github.com/FoundationDB/fdb-record-layer/iss
5252
* **Feature** Allow scrubbing of indexes in READABLE_UNIQUE_PENDING state [(Issue #3135)](https://github.com/FoundationDB/fdb-record-layer/issues/3135)
5353
* **Feature** Support Lucene index scrubbing [(Issue #3008)](https://github.com/FoundationDB/fdb-record-layer/issues/3008)
5454

55+
<!-- MIXED_MODE_RESULTS 4.1.6.0 PLACEHOLDER -->
56+
5557
### 4.1.4.0
5658

5759
* **Bug fix** Ungrouped GROUP BY queries result in infinite continuations when maxRows is 1 [(Issue #3093)](https://github.com/FoundationDB/fdb-record-layer/issues/3093)

gradle/testing.gradle

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import java.util.regex.Matcher
2+
13
/*
24
* testing.gradle
35
*
@@ -56,9 +58,58 @@ task quickTest(type: Test) {
5658
}
5759
}
5860

61+
def getMixedModeVersion(descriptor) {
62+
while (descriptor != null) {
63+
// don't display this or any of the parents. Let's assume that nobody ever
64+
// sets the display name in code to start with "Gradle Test Executor".
65+
// it appears to be suffixed with a number, but I didn't investigate why
66+
if (descriptor.displayName.startsWith("Gradle Test Executor")) {
67+
break
68+
}
69+
70+
Matcher versionMatch = descriptor.displayName =~ /^MultiServer \((?:((?:\d+\.)+\d+) then Embedded|Embedded then ((?:\d+\.)+\d+))\)/
71+
if (versionMatch.size() != 0) {
72+
def version = versionMatch[0][1]
73+
if (version == null) {
74+
version = versionMatch[0][2]
75+
}
76+
return version
77+
}
78+
descriptor = descriptor.parent
79+
}
80+
return null
81+
}
82+
83+
task mixedModeTest(type: Test) {
84+
useJUnitPlatform {
85+
includeTags 'MixedMode'
86+
}
87+
88+
ignoreFailures = true
89+
90+
// Skip non-MultiServer yaml tests
91+
systemProperties['tests.mixedModeOnly'] = 'true'
92+
93+
def markdownReports = new File("$rootDir/.out/reports/")
94+
def mixedModeResults = new File(markdownReports, "mixed-mode-results.log")
95+
doFirst {
96+
// This may have issues with running tasks concurrently
97+
if (!markdownReports.exists()) {
98+
markdownReports.mkdirs()
99+
}
100+
}
101+
afterTest { descriptor, result ->
102+
def version = getMixedModeVersion(descriptor)
103+
if (version == null) {
104+
throw new RuntimeException("Could not find version " + getFullDisplayName(descriptor))
105+
} else {
106+
mixedModeResults.append("${result.resultType} ${version}\n")
107+
}
108+
}
109+
}
110+
59111
def getFullDisplayName(descriptor) {
60-
def fullName = ""
61-
fullName = descriptor.displayName
112+
def fullName = descriptor.displayName
62113
descriptor = descriptor.parent
63114
while (descriptor != null) {
64115
// don't display this or any of the parents. Let's assume that nobody ever
@@ -89,8 +140,9 @@ def configureTestTask = { propertyPrefix, task ->
89140
propertyPrefix + '.forkEvery']
90141
System.properties.each { prop ->
91142
def prefix = "${propertyPrefix}.sysProp."
92-
if (!prop.key.startsWith(prefix.toString()))
93-
return;
143+
if (!prop.key.startsWith(prefix.toString())) {
144+
return
145+
};
94146
def setkey = prop.key.substring(prefix.length())
95147
task.systemProperties[setkey] = prop.value
96148
logger.debug "Set system property ${setkey} = ${prop.value} on ${propertyPrefix}"

yaml-tests/src/main/java/com/apple/foundationdb/relational/yamltests/YamlTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
package com.apple.foundationdb.relational.yamltests;
2222

23+
import org.junit.jupiter.api.Tag;
2324
import org.junit.jupiter.api.extension.ExtendWith;
2425

2526
import java.lang.annotation.Retention;
@@ -43,6 +44,9 @@
4344
*/
4445
@Retention(RetentionPolicy.RUNTIME)
4546
@ExtendWith(YamlTestExtension.class)
47+
// Right now these are the only tests that have the capability to run in mixed mode, but if we create other tests, this
48+
// should be moved to a shared static location
49+
@Tag("MixedMode")
4650
public @interface YamlTest {
4751
/**
4852
* Simple interface to run a {@code .yamsql} file, based on the config.

yaml-tests/src/main/java/com/apple/foundationdb/relational/yamltests/YamlTestExtension.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,13 @@ public void beforeAll(final ExtensionContext context) throws Exception {
8181
for (ExternalServer server : servers) {
8282
server.start();
8383
}
84+
final boolean mixedModeOnly = Boolean.parseBoolean(System.getProperty("tests.mixedModeOnly", "false"));
85+
final Stream<YamlTestConfig> localTestingConfigs = mixedModeOnly ?
86+
Stream.of() :
87+
Stream.of(new EmbeddedConfig(), new JDBCInProcessConfig());
8488
testConfigs = Stream.concat(
8589
// The configs for local testing (single server)
86-
Stream.of(new EmbeddedConfig(), new JDBCInProcessConfig()),
90+
localTestingConfigs,
8791
// The configs for multi-server testing (4 configs for each server available)
8892
servers.stream().flatMap(server ->
8993
Stream.of(new MultiServerConfig(0, server),

yaml-tests/yaml-tests.gradle

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import java.util.jar.JarFile
2+
import java.util.regex.Matcher
3+
14
/*
25
* yaml-tests.gradle
36
*
@@ -104,20 +107,55 @@ ext.resolveOtherServer = { Set<String> rejectedVersions ->
104107
})
105108
def configuration = configurations.getByName(configurationName, { })
106109

107-
def resolution = configuration.resolve()[0]
108-
def versionMatch = resolution.getName() =~ /^fdb-relational-server-(.*-SNAPSHOT)-all.jar$/
110+
File resolution = configuration.resolve()[0]
111+
Matcher versionMatch = resolution.getName() =~ /^fdb-relational-server-(.*-SNAPSHOT)-all.jar$/
109112
if (versionMatch.size() != 0) {
110-
System.out.println("Rejecting old external server: " + resolution.getName())
113+
println("Rejecting old external server: " + resolution.getName())
111114
def version = versionMatch[0][1]
112115
// check that the version is new, to more obviously catch potential infinite loops
113116
assert rejectedVersions.add(version)
114117
return resolveOtherServer(rejectedVersions)
115118
}
116-
System.out.println("Downloaded old external server: " + resolution.getName())
119+
println("Downloaded old external server: " + resolution.getName())
117120
return resolution
118121
}
119122
}
120123

124+
static def getAttributesFromJar(File file) {
125+
try (JarFile jarFile = new JarFile(file)) {
126+
java.util.jar.Manifest manifest = jarFile.getManifest()
127+
java.util.jar.Attributes mainAttributes = manifest.getMainAttributes()
128+
String version = mainAttributes.getValue("Specification-Version")
129+
// It looks like `Build-Date` is not currently being included, if we had that we could guarantee the last
130+
// x weeks of builds. There may need to be some ways to handle the fact that built date may not be consistent
131+
// with the ordering of the versions
132+
if (version != null) {
133+
return [version: version]
134+
} else {
135+
throw new RuntimeException("Server does not specify a version in the manifest: " + file.getAbsolutePath())
136+
}
137+
}
138+
}
139+
140+
ext.resolveManyServers = { ->
141+
Set<File> selectedServers = new HashSet<>();
142+
Set<String> rejectedVersions = new HashSet<>();
143+
while (selectedServers.size() < 50) {
144+
def serverFile = resolveOtherServer(rejectedVersions)
145+
def attributes = getAttributesFromJar(serverFile)
146+
// 4.0.559.0 is the first version that introduced the server, so we won't be able to find anything
147+
// older than that. Eventually we can remove this check because we'll be long enough away in terms of versions
148+
// and times.
149+
if (attributes.version == "4.0.559.0") {
150+
break
151+
}
152+
rejectedVersions.add(attributes.version)
153+
selectedServers.add(serverFile)
154+
}
155+
println("Found ${selectedServers.size()} to test against")
156+
return selectedServers
157+
}
158+
121159
task cleanExternalServerDirectory(type: Delete) {
122160
delete project.layout.buildDirectory.dir('externalServer')
123161
}
@@ -131,6 +169,19 @@ task serverJars(type: Copy) {
131169
}
132170
}
133171

172+
task downloadManyExternalServers(type: Copy) {
173+
dependsOn "cleanExternalServerDirectory"
174+
from resolveManyServers()
175+
into project.layout.buildDirectory.dir('externalServer')
176+
}
177+
178+
mixedModeTest {
179+
dependsOn("downloadManyExternalServers")
180+
systemProperty("yaml_testing_external_server", project.layout.buildDirectory.dir('externalServer').get().asFile)
181+
// this is specified in testing.gradle, but it looks like it needs to be repeated here.
182+
ignoreFailures = true
183+
}
184+
134185
test {
135186
dependsOn "serverJars"
136187
systemProperty("yaml_testing_external_server", project.layout.buildDirectory.dir('externalServer').get().asFile)

0 commit comments

Comments
 (0)