+
+
+
+
diff --git a/.github/scripts/parse-trx-to-json.py b/.github/scripts/parse-trx-to-json.py
new file mode 100644
index 000000000..09b7cc117
--- /dev/null
+++ b/.github/scripts/parse-trx-to-json.py
@@ -0,0 +1,313 @@
+#!/usr/bin/env python3
+# Copyright (c) 2025 ADBC Drivers Contributors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Parse one or more VSTest .trx files into a single nightly-run JSON record.
+
+Usage:
+ parse-trx-to-json.py OUTPUT.json TRX [TRX ...]
+
+The output record captures the totals, per-test outcomes for everything that
+did not pass, and a coarse "failure signature" for each failure so the
+dashboard can group 100+ failures into a handful of root causes. Run metadata
+(commit, branch, run id, timestamp, …) is read from the environment so the
+script stays decoupled from the workflow.
+"""
+import json
+import os
+import re
+import sys
+import xml.etree.ElementTree as ET
+
+# TRX uses a default namespace; strip it so we can query with plain tag names.
+_NS = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
+
+
+def _localname(tag):
+ return tag.split("}", 1)[-1] if "}" in tag else tag
+
+
+def signature_for(message):
+ """Collapse a failure message into a stable, human-readable bucket.
+
+ The goal is triage, not precision: hundreds of parameterized failures that
+ share one root cause (a 404 warehouse, a read-only rejection, …) should
+ land in the same bucket regardless of the per-case query/value text.
+ """
+ if not message:
+ return "Unknown / no message"
+ m = message.replace("\r", " ").replace("\n", " ")
+
+ # Order matters: the most specific patterns win. In particular the
+ # Thrift-on-SEA and CloudFetch buckets must be matched before the generic
+ # "Couldn't connect / HttpRequestException" transport bucket, because their
+ # messages also contain that text.
+ patterns = [
+ # --- Reyden capability gaps (expected; see SIG_CATEGORY) -------------
+ # A Thrift session against a SEA/REST-only warehouse (e.g. Reyden) has
+ # no Thrift endpoint, so the server returns ENDPOINT_NOT_FOUND. This is
+ # NOT a missing/misconfigured warehouse — the warehouse works over REST.
+ (r"Thrift server error.*ENDPOINT_NOT_FOUND|ENDPOINT_NOT_FOUND.*Thrift server error",
+ "Thrift endpoint unavailable on SEA/Reyden warehouse"),
+ (r"Error in download process|CloudFetch.*download",
+ "CloudFetch not supported on Reyden (download failed)"),
+ # Specific unsupported features, split out so the dashboard shows each
+ # distinct gap (count + what it means) rather than one opaque bucket.
+ (r"Unsupported statement:\s*SHOW COLUMNS", "Unsupported statement: SHOW COLUMNS"),
+ (r"Unsupported CREATE type:\s*SCHEMA", "Unsupported CREATE type: SCHEMA"),
+ (r"CREATE OR REPLACE TABLE", "Unsupported feature: CREATE OR REPLACE TABLE"),
+ (r"interval year to month|interval day to second", "Unsupported type: INTERVAL"),
+ # Known Reyden backend bug: the Statement Execution API does not return a
+ # correct num_affected_rows for INSERT/UPDATE, so the driver surfaces -1.
+ # Surfaces as an xUnit "Expected: 1 / Actual: -1" assertion or an explicit
+ # "non-negative affected rows, got -1" — the root cause is the Reyden
+ # backend, not the driver, so it belongs in the expected-gap bucket. Must
+ # precede the generic assertion bucket below. Matched on "Actual: -1"
+ # (anchored with \b so -10/-100 don't match), which only the affected-rows
+ # count produces (ADBC's "unknown/not applicable" sentinel).
+ (r"non-negative affected rows|Actual:\s*-1\b",
+ "Reyden rows_affected not returned (known backend bug)"),
+ # Reyden does not expose the hive_metastore catalog, so a test asserting
+ # the default catalog is "hive_metastore" sees "main" instead. Placed
+ # after the SHOW COLUMNS bucket (whose messages can also mention
+ # hive_metastore) so only the catalog-default assertion lands here.
+ (r"hive_metastore", "Reyden does not support hive_metastore catalog"),
+ # Any other unsupported DDL/statement/type Reyden rejects.
+ (r"PARSER_UNSUPPORTED_FEATURE|UNSUPPORTED_FEATURE|DELTA_UNSUPPORTED|Unsupported statement|"
+ r"Unsupported CREATE type|Unsupported Delta table type|Unsupported .*type",
+ "Reyden unsupported feature (other)"),
+ # --- Environment / infra --------------------------------------------
+ (r"ENDPOINT_NOT_FOUND", "Warehouse not found (ENDPOINT_NOT_FOUND / HTTP 404)"),
+ (r"read[- ]?only|READ_ONLY|cannot be modified|not.*allowed.*read", "Read-only warehouse rejected write/DDL"),
+ (r"PERMISSION_DENIED|not authorized|Forbidden|HTTP 403", "Permission denied (403)"),
+ (r"timeout|timed out|TimeoutException", "Timeout"),
+ (r"Couldn't connect|connection refused|HttpRequestException", "Connection / transport error"),
+ # --- Confirmed Reyden SEA backend bugs (filed under Epic SC-222102) -
+ # Reproduced against Reyden vs DBSQL / raw SEA API and filed as [M4]
+ # tickets. Matched ahead of the generic syntax/assertion buckets so each
+ # gets its own row and lands in CAT_REAL (the real, tracked-bug bucket)
+ # rather than the catch-alls. Everything NOT matched here is a known
+ # Reyden limitation (CAT_REYDEN_GAP) — see the category map below.
+ (r"at or near 'CATALOGS'", "Reyden SEA rejects SHOW SCHEMAS/TABLES IN ALL CATALOGS"),
+ (r"Expected identifier but got end of input", "Reyden SEA rejects bare SET statement"),
+ (r"Actual:\s*\"?col_0\"?", "Reyden SEA omits column aliases (returns col_0)"),
+ # --- Genuine SQL errors with specific codes ------------------------
+ # Checked before the broad assertion bucket below, whose "expected"
+ # token also appears in "Expected identifier ..." syntax-error text.
+ (r"PARSE_SYNTAX_ERROR|SYNTAX_ERROR", "SQL syntax error"),
+ # --- Confirmed Reyden bug: ANSI strict-cast (filed SC-233355) -------
+ # Heterogeneous ARRAY/MAP literals fail to coerce on Reyden under ANSI.
+ (r"CAST_INVALID_INPUT", "Type cast mismatch on round-trip"),
+ (r"TABLE_OR_VIEW_NOT_FOUND|cannot be found|does not exist", "Object not found"),
+ # Generic value mismatch — after the specific tracked-bug signatures
+ # above are extracted, the remaining value mismatches on Reyden are
+ # known limitations (e.g. write-not-persisted), so this is mapped to
+ # CAT_REYDEN_GAP, not the real-bug bucket.
+ (r"Assert\.|Equal\(|Xunit|expected", "Assertion failed (value mismatch)"),
+ # --- Generic DML/DDL rejection (catch-all, lowest priority) --------
+ (r"INSERT|UPDATE|DELETE|MERGE|CREATE TABLE|DROP TABLE|ALTER TABLE", "DML/DDL rejected"),
+ ]
+ for pat, label in patterns:
+ if re.search(pat, m, re.IGNORECASE):
+ return label
+ # Fall back to the exception type at the head of the message, if present.
+ exc = re.match(r"\s*([A-Za-z0-9_.]+Exception)", m)
+ if exc:
+ return exc.group(1)
+ return "Other"
+
+
+# Root-cause category layered on top of the fine-grained signature. The
+# dashboard rolls failures up to these three buckets so a low raw pass-rate
+# (dominated by expected Reyden gaps) doesn't mask the genuine driver bugs.
+#
+# Classification hinges on WHICH step failed, which the message already encodes:
+# - A test with a CREATE TABLE/SCHEMA step that Reyden can't run fails AT that
+# step with an "Unsupported …" message -> CAT_REYDEN_GAP (expected).
+# - CAT_REAL is now the set of CONFIRMED + tracked Reyden SEA bugs: each was
+# reproduced (Reyden vs DBSQL / raw SEA API) and filed under Epic SC-222102.
+# Only the four specific signatures above (IN ALL CATALOGS, bare SET, col_0
+# aliases, ANSI cast) land here. Every other failure on this Reyden-only
+# nightly is a known limitation -> CAT_REYDEN_GAP, including the generic
+# value-mismatch / DML-rejected buckets.
+CAT_REYDEN_GAP = "Reyden capability gap (expected)"
+CAT_ENVIRONMENT = "Environment / infra"
+CAT_REAL = "Real issue / to investigate"
+
+# Explicit signature -> category map. Any signature not listed here (including
+# the dynamic "" fallbacks and the value/cast/DML/syntax buckets)
+# is treated as CAT_REAL so genuine, unclassified failures surface rather than
+# hide.
+_SIGNATURE_CATEGORY = {
+ "Thrift endpoint unavailable on SEA/Reyden warehouse": CAT_REYDEN_GAP,
+ "CloudFetch not supported on Reyden (download failed)": CAT_REYDEN_GAP,
+ "Unsupported statement: SHOW COLUMNS": CAT_REYDEN_GAP,
+ "Unsupported CREATE type: SCHEMA": CAT_REYDEN_GAP,
+ "Unsupported feature: CREATE OR REPLACE TABLE": CAT_REYDEN_GAP,
+ "Unsupported type: INTERVAL": CAT_REYDEN_GAP,
+ "Reyden rows_affected not returned (known backend bug)": CAT_REYDEN_GAP,
+ "Reyden does not support hive_metastore catalog": CAT_REYDEN_GAP,
+ "Reyden unsupported feature (other)": CAT_REYDEN_GAP,
+ # Back-compat: older runs used one combined signature.
+ "Reyden unsupported feature (DDL / statement / type)": CAT_REYDEN_GAP,
+ "Warehouse not found (ENDPOINT_NOT_FOUND / HTTP 404)": CAT_ENVIRONMENT,
+ "Read-only warehouse rejected write/DDL": CAT_ENVIRONMENT,
+ "Permission denied (403)": CAT_ENVIRONMENT,
+ "Timeout": CAT_ENVIRONMENT,
+ "Connection / transport error": CAT_ENVIRONMENT,
+ # Generic value-mismatch and DML/DDL-rejected on this Reyden-only nightly are
+ # known limitations (the specific real bugs have their own signatures above).
+ "Assertion failed (value mismatch)": CAT_REYDEN_GAP,
+ "DML/DDL rejected": CAT_REYDEN_GAP,
+ # The four tracked Reyden SEA bugs ("Reyden SEA rejects …", "Reyden SEA omits
+ # column aliases (returns col_0)", "Type cast mismatch on round-trip") and any
+ # unclassified "" / "SQL syntax error" / "Object not found" /
+ # "Other" fall through to CAT_REAL so genuine new issues still surface.
+}
+
+
+def category_for(signature):
+ """Map a fine-grained signature to one of the four root-cause buckets."""
+ return _SIGNATURE_CATEGORY.get(signature, CAT_REAL)
+
+
+def class_of(test_name):
+ """Best-effort owning class: strip the parameter list and the method."""
+ base = test_name.split("(", 1)[0]
+ return base.rsplit(".", 1)[0] if "." in base else base
+
+
+def parse_trx(path):
+ tree = ET.parse(path)
+ root = tree.getroot()
+
+ # Map testId -> testName from the section.
+ names = {}
+ for ut in root.iter():
+ if _localname(ut.tag) == "UnitTest":
+ tm = None
+ for child in ut:
+ if _localname(child.tag) == "TestMethod":
+ tm = child
+ break
+ tid = ut.get("id")
+ if tid is not None and tm is not None:
+ cls = tm.get("className", "")
+ name = tm.get("name", "")
+ names[tid] = (f"{cls}.{name}" if cls else name)
+
+ results = []
+ for r in root.iter():
+ if _localname(r.tag) != "UnitTestResult":
+ continue
+ outcome = r.get("outcome", "")
+ tid = r.get("testId")
+ test_name = names.get(tid, r.get("testName", "unknown"))
+ message = ""
+ stack = ""
+ for out in r:
+ if _localname(out.tag) != "Output":
+ continue
+ for err in out:
+ if _localname(err.tag) == "ErrorInfo":
+ for e in err:
+ ln = _localname(e.tag)
+ if ln == "Message":
+ message = (e.text or "").strip()
+ elif ln == "StackTrace":
+ stack = (e.text or "").strip()
+ results.append({
+ "name": test_name,
+ "class": class_of(test_name),
+ "outcome": outcome,
+ "message": message,
+ "stack": stack,
+ })
+ return results
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("usage: parse-trx-to-json.py OUTPUT.json TRX [TRX ...]", file=sys.stderr)
+ sys.exit(2)
+
+ out_path = sys.argv[1]
+ trx_paths = sys.argv[2:]
+
+ all_results = []
+ for p in trx_paths:
+ if os.path.isdir(p):
+ for name in sorted(os.listdir(p)):
+ if name.endswith(".trx"):
+ all_results.extend(parse_trx(os.path.join(p, name)))
+ elif os.path.exists(p):
+ all_results.extend(parse_trx(p))
+ else:
+ print(f"WARNING: {p} not found, skipping", file=sys.stderr)
+
+ passed = [r for r in all_results if r["outcome"] == "Passed"]
+ failed = [r for r in all_results if r["outcome"] == "Failed"]
+ skipped = [r for r in all_results if r["outcome"] in ("NotExecuted", "Inconclusive")]
+
+ # Trim payload: keep full detail for failures only.
+ for r in failed:
+ r["signature"] = signature_for(r["message"])
+ r["category"] = category_for(r["signature"])
+ if len(r["message"]) > 2000:
+ r["message"] = r["message"][:2000] + " …(truncated)"
+ if len(r["stack"]) > 2000:
+ r["stack"] = r["stack"][:2000] + " …(truncated)"
+
+ by_signature = {}
+ by_class = {}
+ by_category = {}
+ for r in failed:
+ by_signature[r["signature"]] = by_signature.get(r["signature"], 0) + 1
+ by_class[r["class"]] = by_class.get(r["class"], 0) + 1
+ by_category[r["category"]] = by_category.get(r["category"], 0) + 1
+
+ total = len(all_results)
+ record = {
+ "run_id": os.environ.get("GITHUB_RUN_ID", ""),
+ "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT", ""),
+ "run_number": os.environ.get("GITHUB_RUN_NUMBER", ""),
+ "timestamp": os.environ.get("RUN_TIMESTAMP", ""),
+ "commit": os.environ.get("GITHUB_SHA", ""),
+ "branch": os.environ.get("GITHUB_REF_NAME", ""),
+ "protocol": os.environ.get("RUN_PROTOCOL", ""),
+ "read_only": os.environ.get("RUN_READ_ONLY", "") == "true",
+ "workflow": os.environ.get("GITHUB_WORKFLOW", ""),
+ "html_url": (
+ f"{os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/"
+ f"{os.environ.get('GITHUB_REPOSITORY', '')}/actions/runs/"
+ f"{os.environ.get('GITHUB_RUN_ID', '')}"
+ ),
+ "total": total,
+ "passed": len(passed),
+ "failed": len(failed),
+ "skipped": len(skipped),
+ "pass_rate": round(100.0 * len(passed) / total, 1) if total else 0.0,
+ "by_category": dict(sorted(by_category.items(), key=lambda kv: -kv[1])),
+ "by_signature": dict(sorted(by_signature.items(), key=lambda kv: -kv[1])),
+ "by_class": dict(sorted(by_class.items(), key=lambda kv: -kv[1])),
+ "failures": sorted(failed, key=lambda r: (r["category"], r["signature"], r["name"])),
+ }
+
+ with open(out_path, "w") as f:
+ json.dump(record, f, indent=2)
+ print(f"Parsed {total} results ({record['passed']} passed, "
+ f"{record['failed']} failed, {record['skipped']} skipped) -> {out_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/update-e2e-dashboard.py b/.github/scripts/update-e2e-dashboard.py
new file mode 100644
index 000000000..d9b7b7e8b
--- /dev/null
+++ b/.github/scripts/update-e2e-dashboard.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+# Copyright (c) 2025 ADBC Drivers Contributors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Merge a parsed nightly-run record into the gh-pages E2E dashboard.
+
+Usage:
+ update-e2e-dashboard.py RUN_RECORD.json DASHBOARD_DIR TEMPLATE_INDEX.html
+
+ RUN_RECORD.json output of parse-trx-to-json.py
+ DASHBOARD_DIR e.g. /e2e-nightly
+ TEMPLATE_INDEX static dashboard page to (re)publish alongside the data
+
+Layout produced under DASHBOARD_DIR:
+ index.html the dashboard (copied from template)
+ data/runs.json lightweight history index (one row/run)
+ data/run--.json full per-run detail (failure list)
+
+History is append-only and keyed by (run_id, protocol) so a re-run replaces
+its prior row rather than duplicating it.
+"""
+import json
+import os
+import shutil
+import sys
+
+# Keep history bounded so the index stays small and the page stays fast.
+MAX_RUNS = 365
+
+
+def main():
+ if len(sys.argv) != 4:
+ print("usage: update-e2e-dashboard.py RUN_RECORD.json DASHBOARD_DIR TEMPLATE_INDEX.html",
+ file=sys.stderr)
+ sys.exit(2)
+
+ record_path, dashboard_dir, template = sys.argv[1], sys.argv[2], sys.argv[3]
+ data_dir = os.path.join(dashboard_dir, "data")
+ os.makedirs(data_dir, exist_ok=True)
+
+ with open(record_path) as f:
+ record = json.load(f)
+
+ run_id = record.get("run_id", "0")
+ protocol = record.get("protocol", "") or "unknown"
+ detail_name = f"run-{run_id}-{protocol}.json"
+
+ # Full detail (incl. failure messages) lives in its own file, fetched
+ # lazily by the dashboard only when a run is expanded.
+ with open(os.path.join(data_dir, detail_name), "w") as f:
+ json.dump(record, f, indent=2)
+
+ # Lightweight summary row for the history index — drop the heavy bits.
+ summary = {k: record[k] for k in (
+ "run_id", "run_attempt", "run_number", "timestamp", "commit", "branch",
+ "protocol", "read_only", "html_url", "total", "passed", "failed",
+ "skipped", "pass_rate", "by_category", "by_signature", "by_class",
+ ) if k in record}
+ summary["detail"] = detail_name
+
+ runs_path = os.path.join(data_dir, "runs.json")
+ runs = []
+ if os.path.exists(runs_path):
+ try:
+ with open(runs_path) as f:
+ runs = json.load(f)
+ except (ValueError, OSError):
+ runs = []
+
+ key = (summary.get("run_id"), summary.get("protocol"))
+ runs = [r for r in runs if (r.get("run_id"), r.get("protocol")) != key]
+ runs.append(summary)
+ runs.sort(key=lambda r: (r.get("timestamp", ""), r.get("run_id", "")))
+ if len(runs) > MAX_RUNS:
+ runs = runs[-MAX_RUNS:]
+
+ with open(runs_path, "w") as f:
+ json.dump(runs, f, indent=2)
+
+ shutil.copyfile(template, os.path.join(dashboard_dir, "index.html"))
+ print(f"Dashboard updated: {len(runs)} runs in index, detail -> {detail_name}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/reyden-rest-nightly.yml b/.github/workflows/reyden-rest-nightly.yml
index 94f0ef4b0..5487866a3 100644
--- a/.github/workflows/reyden-rest-nightly.yml
+++ b/.github/workflows/reyden-rest-nightly.yml
@@ -12,6 +12,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+# Nightly E2E run against the Reyden REST warehouse.
+#
+# Self-contained workflow: the generic e2e-tests.yml does not carry any
+# Reyden-specific routing (Reyden rejects DDL on the SQL statement API), so
+# all Reyden-aware setup, test-config emission, test execution, and result
+# publishing lives in this file. Setup/teardown talks to a regular DBSQL
+# warehouse (TEST_PECO_WAREHOUSE_HTTP_PATH) for the DDL the Reyden warehouse
+# would refuse; only the actual test run targets the Reyden warehouse
+# (TEST_PECO_REYDEN_HTTP_PATH).
name: Reyden REST Nightly E2E
on:
@@ -20,6 +29,10 @@ on:
- cron: '0 2 * * *'
workflow_dispatch:
+# publish-dashboard pushes the parsed test results to the gh-pages branch.
+permissions:
+ contents: write
+
concurrency:
group: ${{ github.repository }}-reyden-rest-nightly
cancel-in-progress: false
@@ -30,7 +43,10 @@ jobs:
runs-on: ubuntu-latest
env:
DATABRICKS_SERVER_HOSTNAME: ${{ secrets.DATABRICKS_HOST }}
+ # Reyden warehouse — used only for test execution (no DDL support)
DATABRICKS_HTTP_PATH: ${{ secrets.TEST_PECO_REYDEN_HTTP_PATH }}
+ # Regular DBSQL warehouse — used for schema/table setup and teardown
+ SETUP_HTTP_PATH: ${{ secrets.TEST_PECO_WAREHOUSE_HTTP_PATH }}
DATABRICKS_TEST_CLIENT_ID: ${{ secrets.DATABRICKS_TEST_CLIENT_ID }}
DATABRICKS_TEST_CLIENT_SECRET: ${{ secrets.DATABRICKS_TEST_CLIENT_SECRET }}
steps:
@@ -68,9 +84,10 @@ jobs:
PER_RUN_SCHEMA="adbc_testing_run_${{ github.run_id }}_${{ github.run_attempt }}_rest"
echo "PER_RUN_SCHEMA=$PER_RUN_SCHEMA" >> $GITHUB_OUTPUT
echo "Per-run schema: main.$PER_RUN_SCHEMA"
- WAREHOUSE_ID=$(echo "${{ env.DATABRICKS_HTTP_PATH }}" | awk -F/ '{print $NF}')
+ # Use the regular warehouse ID for setup operations
+ WAREHOUSE_ID=$(echo "${{ env.SETUP_HTTP_PATH }}" | awk -F/ '{print $NF}')
if [ -z "$WAREHOUSE_ID" ]; then
- echo "ERROR: Could not extract warehouse id from DATABRICKS_HTTP_PATH"
+ echo "ERROR: Could not extract warehouse id from SETUP_HTTP_PATH"
exit 1
fi
echo "WAREHOUSE_ID=$WAREHOUSE_ID" >> $GITHUB_OUTPUT
@@ -139,6 +156,21 @@ jobs:
sys.exit(1)
print(f"OK [{i}/{len(statements)}]: {stmt[:80].replace(chr(10), ' ')}")
print(f"Seeded {len(statements)} statements into main.{os.environ['PER_RUN_SCHEMA']}")
+
+ # Create an empty mutable table for INSERT tests. Reyden supports INSERT but
+ # not CREATE TABLE, so the table has to be pre-created here using the regular
+ # warehouse before tests start writing to it.
+ schema = os.environ["PER_RUN_SCHEMA"]
+ mutable_stmt = f"CREATE TABLE IF NOT EXISTS main.{schema}.adbc_testing_mutable (id INT, name STRING) USING DELTA"
+ payload = json.dumps({"warehouse_id": warehouse, "statement": mutable_stmt, "wait_timeout": "30s"}).encode()
+ req = urllib.request.Request(url, data=payload,
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, method="POST")
+ with urllib.request.urlopen(req) as resp:
+ body = json.loads(resp.read())
+ if body.get("status", {}).get("state", "") != "SUCCEEDED":
+ print(f"ERROR: CREATE mutable table failed: {json.dumps(body, indent=2)}")
+ sys.exit(1)
+ print(f"Created mutable table: main.{schema}.adbc_testing_mutable")
PYEOF
- name: Create Databricks config file
@@ -162,6 +194,8 @@ jobs:
"query": "SELECT * FROM main.${PER_RUN_SCHEMA}.adbc_testing_table",
"expectedResults": 12,
"isCITesting": true,
+ "isReadOnly": true,
+ "mutableTable": "main.${PER_RUN_SCHEMA}.adbc_testing_mutable",
"tracePropagationEnabled": "true",
"traceParentHeaderName": "traceparent",
"traceStateEnabled": "false",
@@ -180,12 +214,30 @@ jobs:
run: |
./ci/scripts/csharp_build.sh "${{ github.workspace }}"
- - name: Run All Tests
+ - name: Run E2E tests
shell: bash
run: |
export DATABRICKS_TEST_CONFIG_FILE="$HOME/.databricks/connection.json"
- ./ci/scripts/csharp_test_databricks_e2e.sh "${{ github.workspace }}"
+ # Nightly only — skip unit tests (Tests.Unit namespace). Always emit a
+ # TRX so the dashboard can parse pass/fail/skip + per-test failure
+ # detail; set -e in the script still writes the TRX before the
+ # non-zero exit and the upload step is always().
+ ./ci/scripts/csharp_test_databricks_e2e.sh "${{ github.workspace }}" \
+ --filter FullyQualifiedName!~Tests.Unit \
+ --logger "trx;LogFileName=results-rest.trx" \
+ --results-directory "${{ github.workspace }}/TestResults"
+ - name: Upload test results (TRX)
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: e2e-trx-rest
+ path: ${{ github.workspace }}/TestResults/*.trx
+ if-no-files-found: warn
+ retention-days: 7
+
+ # Always run, even on test failure or job cancellation, so we don't leak
+ # schemas in the workspace.
- name: Drop per-run schema
if: always() && steps.schema.outputs.PER_RUN_SCHEMA != ''
env:
@@ -201,8 +253,73 @@ jobs:
-d "$PAYLOAD")
STATUS=$(echo "$RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('status', {}).get('state', ''))")
if [ "$STATUS" != "SUCCEEDED" ]; then
+ # Don't fail the job on cleanup error — leaked schemas can be
+ # garbage-collected later. But surface the failure.
echo "WARNING: DROP SCHEMA failed (status=$STATUS)"
echo "Response: $RESPONSE"
else
echo "Dropped schema: main.${PER_RUN_SCHEMA}"
fi
+
+ # Parse the TRX results and publish an append-only history to the gh-pages
+ # branch (served at https://adbc-drivers.github.io/databricks/e2e-nightly/).
+ # Runs always() so a failed test run is still recorded — surfacing failures
+ # is the whole point of the page.
+ publish-dashboard:
+ name: Publish E2E Dashboard
+ needs: reyden-rest-nightly
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ fetch-depth: 0
+
+ - name: Download test results
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ pattern: e2e-trx-*
+ path: trx
+
+ - name: Build dashboard and push to gh-pages
+ env:
+ # The Reyden nightly always exercises the read-only Reyden warehouse,
+ # so the parsed run record is tagged read-only for the dashboard.
+ RUN_READ_ONLY: 'true'
+ run: |
+ set -e
+ shopt -s nullglob
+ RUN_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ export RUN_TIMESTAMP
+
+ dirs=(trx/e2e-trx-*)
+ if [ ${#dirs[@]} -eq 0 ]; then
+ echo "No TRX artifacts found — nothing to publish."
+ exit 0
+ fi
+
+ git fetch origin gh-pages
+ git worktree add /tmp/ghp gh-pages
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ for d in "${dirs[@]}"; do
+ proto="${d#trx/e2e-trx-}"
+ export RUN_PROTOCOL="$proto"
+ echo "::group::Parsing $proto"
+ python3 .github/scripts/parse-trx-to-json.py "/tmp/record-${proto}.json" "$d"
+ python3 .github/scripts/update-e2e-dashboard.py \
+ "/tmp/record-${proto}.json" /tmp/ghp/e2e-nightly .github/e2e-dashboard/index.html
+ echo "::endgroup::"
+ done
+
+ cd /tmp/ghp
+ git add e2e-nightly
+ if git diff --cached --quiet; then
+ echo "No dashboard changes to commit."
+ else
+ git commit -m "e2e-nightly: record run ${GITHUB_RUN_ID} (${GITHUB_REF_NAME})"
+ git push origin gh-pages
+ echo "Published: https://adbc-drivers.github.io/databricks/e2e-nightly/"
+ fi
diff --git a/ci/scripts/csharp_test_databricks_e2e.sh b/ci/scripts/csharp_test_databricks_e2e.sh
index de3a67054..a021a7014 100755
--- a/ci/scripts/csharp_test_databricks_e2e.sh
+++ b/ci/scripts/csharp_test_databricks_e2e.sh
@@ -16,10 +16,8 @@
set -ex
-# Run all tests (both E2E and Unit tests)
source_dir=${1}/csharp/test
pushd ${source_dir}
-# Run all tests in the Databricks test project
-dotnet test --verbosity normal
+dotnet test --verbosity normal ${@:2}
popd