From 1ba8bc667b7a6077c87509cec0e8a86a5f62293c Mon Sep 17 00:00:00 2001 From: he_sk Date: Sun, 27 Sep 2026 03:20:35 +0800 Subject: [PATCH] Verify distributed decimal behavior on real DM MPP --- docs/README_zh.md | 2 +- docs/api-reference.md | 2 +- docs/test-results/2026-09-27-mpp.md | 31 ++++++++++ scripts/verify_dm_mpp.py | 89 +++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 docs/test-results/2026-09-27-mpp.md create mode 100644 scripts/verify_dm_mpp.py diff --git a/docs/README_zh.md b/docs/README_zh.md index 26edc9b..b595ad9 100644 --- a/docs/README_zh.md +++ b/docs/README_zh.md @@ -23,7 +23,7 @@ dmPython 是达梦数据库(DM8)的原生 Python 驱动程序,遵循 [Pyth - **Best-effort(尽力支持)**:尚未纳入 CI 覆盖的扩展使用场景。 - **Not guaranteed(不保证)**:生产 SLA 承诺、厂商认证兼容性与闭源组件支持协议。 - **连接安全**:`ssl_path` 支持使用客户端证书与私钥连接启用加密的 DM8。目录需包含 `ca-cert.pem`、`client-cert.pem`、`client-key.pem`;服务端证书没有 SAN 的旧版本还需提供与服务端完全一致的 `server-cert.pem`。指定 `ssl_path` 时,未协商加密的连接会报错。非空的 `ssl_pwd`、`ukey_name`、`ukey_pin` 仍不支持。 -- **主备路由**:读写分离模式 1 和 4 已在本机 DM8 主备环境、自动提交模式下验证;通过双端点服务名建立的新连接也通过了自动接管后的回归。MPP 集群路由仍需对应环境回归。 +- **主备与 MPP**:读写分离模式 1 和 4 已在本机 DM8 主备环境、自动提交模式下验证;通过双端点服务名建立的新连接也通过了自动接管后的回归。MPP 全局和本地登录已在本机两节点集群验证分布式读写。 ## 路线图与状态 diff --git a/docs/api-reference.md b/docs/api-reference.md index 9ce1a02..963eba3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -61,7 +61,7 @@ dmPython.connect( - `host` 与 `server` 互斥(只允许设置一个)。 - IPv6 地址使用方括号,例如 `server="[::1]"`;`dsn` 可写为 `"[::1]:5236"`。 - `dmsvc_path` 指向包含 `dm_svc.conf` 的目录;连接时可把 `server` 设为配置文件中的服务名。双端点服务名配置 `LOGIN_MODE=1` 后,已在本机 DM8 主备环境验证故障自动接管后的**新连接**会选择晋升的新主库;已有连接的自动恢复尚未验证。 -- `mpp_login` 接受 `DSQL_MPP_LOGIN_GLOBAL` 或 `DSQL_MPP_LOGIN_LOCAL`;`rwseparate` 接受 `DSQL_RWSEPARATE_OFF`、`DSQL_RWSEPARATE_ON` 或 `DSQL_RWSEPARATE_ON2`,`rwseparate_percent` 范围为 0–100。这些选项在建连时传给底层驱动。读写分离模式 1 和 4 已在本机 DM8 主备环境中验证自动提交模式下的查询路由;MPP 只验证了单机 LOCAL 握手,集群路由尚待验证。 +- `mpp_login` 接受 `DSQL_MPP_LOGIN_GLOBAL` 或 `DSQL_MPP_LOGIN_LOCAL`;`rwseparate` 接受 `DSQL_RWSEPARATE_OFF`、`DSQL_RWSEPARATE_ON` 或 `DSQL_RWSEPARATE_ON2`,`rwseparate_percent` 范围为 0–100。这些选项在建连时传给底层驱动。读写分离模式 1 和 4 已在本机 DM8 主备环境中验证自动提交模式下的查询路由;MPP 全局和本地登录已在本机两节点集群验证分布式读写。 - `ssl_path` 指向含 `ca-cert.pem`、`client-cert.pem`、`client-key.pem` 的目录。服务端证书没有 SAN 时还需提供准确的 `server-cert.pem`,用于证书固定校验;有 SAN 的证书按 CA 链和主机名校验。设置后若服务端未协商加密,连接失败。非空的 `ssl_pwd`、`ukey_name`、`ukey_pin` 暂不支持。 - `user` 支持 `user/password@server:port[/schema][?catalog=...]` 形式。 - `login_timeout` 以毫秒为单位,默认 5000,限制首次建连握手;设为 0 表示不限制。`connection_timeout` 以秒为单位,默认 0 不限制,限制 SQL 执行时间。 diff --git a/docs/test-results/2026-09-27-mpp.md b/docs/test-results/2026-09-27-mpp.md new file mode 100644 index 0000000..b544a79 --- /dev/null +++ b/docs/test-results/2026-09-27-mpp.md @@ -0,0 +1,31 @@ +# Real DM8 MPP login and distributed decimal regression (2026-09-27) + +An isolated two-node DM8 MPP cluster ran in local Orb with the 2025-09-24 +ARM image. Each node had a separately initialized database (`EP01`, `EP02`), +matching database creation parameters, `MAL_INI=1`, `MPP_INI=1`, a shared +`dmmal.ini`, and the same `dmmpp.ctl` generated from `dmmpp.ini`. Both nodes +reached `SYSTEM IS READY`, and their logs showed MAL links to each other. +This follows the [DM MPP configuration model](https://eco.dameng.com/document/dm/zh-cn/pm/basic-concepts-principles.html). + +The ARM macOS Python 3.10 extension connected to both endpoints with +`mpp_login=DSQL_MPP_LOGIN_GLOBAL` and `mpp_login=DSQL_MPP_LOGIN_LOCAL`. +`scripts/verify_dm_mpp.py` created a hash-distributed table, batch-inserted +12 `DECIMAL(30,8)` rows, and checked the exact values from both endpoints. +The values included a 20-digit integer part with eight fractional digits, +negative values, and `0.00000001`. + +| Query | EP01 endpoint | EP02 endpoint | +| --- | ---: | ---: | +| Global login | 12 exact rows | 12 exact rows | +| Local login | 6 exact rows | 6 exact rows | + +The two local row ID sets were disjoint and their union equaled the 12 global +rows, proving that the test exercised both physical EPs. The script removed +its test table after success. It takes `DM_MPP_USER`, `DM_MPP_PASSWORD`, +`DM_MPP_EP1_HOST`, and `DM_MPP_EP2_HOST`; optional endpoint ports default to +5236. The account needs permission to create and drop a distributed table. + +The test was run locally because GitHub-hosted runners cannot reach the Orb +cluster. The existing CI matrix uses one DM8 instance and does not exercise +MPP. This result covers a two-EP cluster and one DM8 server build; larger +clusters and MPP primary/standby combinations remain untested. diff --git a/scripts/verify_dm_mpp.py b/scripts/verify_dm_mpp.py new file mode 100644 index 0000000..74d74b3 --- /dev/null +++ b/scripts/verify_dm_mpp.py @@ -0,0 +1,89 @@ +"""Verify local and global login against a real two-node DM MPP cluster. + +Set DM_MPP_USER, DM_MPP_PASSWORD, DM_MPP_EP1_HOST, and DM_MPP_EP2_HOST. +DM_MPP_EP1_PORT and DM_MPP_EP2_PORT default to 5236. The account must be +able to create and drop a distributed table. +""" + +from __future__ import annotations + +import os +import uuid +from decimal import Decimal + +import dmPython + + +HIGH = Decimal("12345678901234567890.12345678") + + +def connect(host: str, port: int, mode: int): + return dmPython.connect( + user=os.environ["DM_MPP_USER"], + password=os.environ["DM_MPP_PASSWORD"], + server=host, + port=port, + mpp_login=mode, + autoCommit=dmPython.DSQL_AUTOCOMMIT_ON, + login_timeout=3000, + ) + + +def rows(conn, table: str): + with conn.cursor() as cur: + cur.execute(f"SELECT ID, CAST(AMOUNT AS VARCHAR(80)) FROM {table} ORDER BY ID") + return {row_id: Decimal(amount) for row_id, amount in cur.fetchall()} + + +def local_instance(conn): + with conn.cursor() as cur: + cur.execute("SELECT INSTANCE_NAME FROM V$INSTANCE") + return cur.fetchone()[0] + + +def main(): + endpoints = ( + (os.environ["DM_MPP_EP1_HOST"], int(os.environ.get("DM_MPP_EP1_PORT", "5236"))), + (os.environ["DM_MPP_EP2_HOST"], int(os.environ.get("DM_MPP_EP2_PORT", "5236"))), + ) + table = "DMPY_MPP_" + uuid.uuid4().hex[:8].upper() + expected = {i: HIGH if i % 2 else -HIGH for i in range(1, 13)} + expected[12] = Decimal("0.00000001") + + with connect(*endpoints[0], dmPython.DSQL_MPP_LOGIN_GLOBAL) as global_conn: + with global_conn.cursor() as cur: + cur.execute( + f"CREATE TABLE {table} (ID INT PRIMARY KEY, AMOUNT DECIMAL(30,8)) " + "DISTRIBUTED BY HASH(ID)" + ) + try: + with global_conn.cursor() as cur: + cur.executemany( + f"INSERT INTO {table} VALUES (?, ?)", list(expected.items()) + ) + + local_rows = [] + instance_names = [] + for host, port in endpoints: + with connect(host, port, dmPython.DSQL_MPP_LOGIN_GLOBAL) as conn: + assert rows(conn, table) == expected + with connect(host, port, dmPython.DSQL_MPP_LOGIN_LOCAL) as conn: + instance_names.append(local_instance(conn)) + local_rows.append(rows(conn, table)) + + assert instance_names[0] != instance_names[1], instance_names + assert local_rows[0] and local_rows[1], "both EPs must store rows" + assert local_rows[0].keys().isdisjoint(local_rows[1].keys()) + assert local_rows[0] | local_rows[1] == expected + print( + f"MPP global: {len(expected)} exact rows from both endpoints; " + f"local: {instance_names[0]}={len(local_rows[0])}, " + f"{instance_names[1]}={len(local_rows[1])}" + ) + finally: + with global_conn.cursor() as cur: + cur.execute(f"DROP TABLE {table}") + + +if __name__ == "__main__": + main()