Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ For production environments, prefer the official [DamengDB/dmPython](https://git
- **Supported build targets**: macOS 14+ ARM64 with CPython 3.9–3.13. Database behavior is supported only where integration tests have evidence.
- **Best-effort**: Extended scenarios not currently covered by CI.
- **Not guaranteed**: Production SLA commitments, vendor-certified compatibility guarantees, and closed-source component support contracts.
- **Connection security**: `ssl_path` supports encrypted DM8 connections with client certificate and key. The directory must contain `ca-cert.pem`, `client-cert.pem`, and `client-key.pem`; bundled legacy server certificates without a SAN also require an exact `server-cert.pem` pin. A plain server is rejected when `ssl_path` is set. Non-empty `ssl_pwd`, `ukey_name`, and `ukey_pin` remain unsupported. MPP and read/write separation settings are passed through, but cluster routing needs a cluster regression environment.
- **Connection security**: `ssl_path` supports encrypted DM8 connections with client certificate and key. The directory must contain `ca-cert.pem`, `client-cert.pem`, and `client-key.pem`; bundled legacy server certificates without a SAN also require an exact `server-cert.pem` pin. A plain server is rejected when `ssl_path` is set. Non-empty `ssl_pwd`, `ukey_name`, and `ukey_pin` remain unsupported.
- **Primary/standby routing**: Read/write separation modes 1 and 4 were verified against a local DM8 primary/standby pair with autocommit enabled. MPP cluster routing still needs a cluster regression environment.

## Roadmap & Status

Expand Down
3 changes: 2 additions & 1 deletion docs/README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ dmPython 是达梦数据库(DM8)的原生 Python 驱动程序,遵循 [Pyth
- **构建支持范围**:macOS 14+ ARM64、CPython 3.9–3.13。数据库行为仅以已有集成测试证据为准。
- **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` 仍不支持;MPP 与读写分离参数已传递到底层驱动,集群路由效果仍需集群环境回归。
- **连接安全**:`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 集群路由仍需对应环境回归。

## 路线图与状态

Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ dmPython.connect(
- `host` 与 `server` 互斥(只允许设置一个)。
- IPv6 地址使用方括号,例如 `server="[::1]"`;`dsn` 可写为 `"[::1]:5236"`。
- `dmsvc_path` 指向包含 `dm_svc.conf` 的目录;连接时可把 `server` 设为配置文件中的服务名。
- `mpp_login` 接受 `DSQL_MPP_LOGIN_GLOBAL` 或 `DSQL_MPP_LOGIN_LOCAL`;`rwseparate` 接受 `DSQL_RWSEPARATE_OFF`、`DSQL_RWSEPARATE_ON` 或 `DSQL_RWSEPARATE_ON2`,`rwseparate_percent` 范围为 0–100。这些选项在建连时传给底层驱动,当前仅验证了单机实例上的 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 只验证了单机 LOCAL 握手,集群路由尚待验证。
- `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 执行时间。
Expand Down
46 changes: 46 additions & 0 deletions docs/test-results/2026-09-27-ha-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Real DM8 primary/standby routing (2026-09-27)

An isolated two-node DM8 watcher group and monitor ran in local Orb, using the
2025-09-24 ARM image. `V$INSTANCE` reported `PRIMARY, OPEN` and `STANDBY, OPEN`
with the same OGUID. Both endpoints accepted direct Python connections.

The image's `DOCKER_DMWATCHER` startup script omitted the `TIME_ZONE` template
substitution. The temporary containers replaced `ENV_21` with `+08:00` before
running the image startup script. This workaround was confined to the local
test containers.

In the cluster network, a Go driver probe found that `rwSeparate=1` sent a
read-only query to the standby, while `rwSeparate=4` failed by dialing `:0`.
The server did not provide a standby address in the mode 4 login response. The
driver now falls back to its existing valid-standby metadata query when that
address is absent.

After the fix, `scripts/verify_dm_ha.py` built and ran with the ARM Linux
Python extension. With autocommit enabled and 0% primary reads:

| Mode | Query destination |
| --- | --- |
| `rwseparate=0` | Primary (`GRP453932_DW1`) |
| `rwseparate=1` | Standby (`GRP453932_DW2`) |
| `rwseparate=4` | Standby (`GRP453932_DW2`) |

The script then inserted `12345678901234567890.12345678` into a
`DECIMAL(30,8)` column through mode 4. A direct standby connection read the
same value after replication, and the script removed its test table.

To repeat the check, provide `DM_HA_PRIMARY_HOST`, `DM_HA_STANDBY_HOST`,
`DM_HA_USER`, and `DM_HA_PASSWORD` to `scripts/verify_dm_ha.py`. Both hosts
must be reachable from the process running the driver. Optional
`DM_HA_PRIMARY_PORT` and `DM_HA_STANDBY_PORT` default to 5236. The account
needs permission to create and drop a table on the primary.

This HA check currently runs locally: the GitHub hosted runners cannot reach
the user's Orb network. The regular GitHub ARM real-database matrix still
checks single-node behavior and build compatibility. MPP routing and UKey
authentication remain unverified.

An additional stop-primary trial did not prove automatic takeover. The
monitor's confirmation mode remained active; after roughly 35 seconds, the
second node still reported `STANDBY, OPEN`. The replicated high-precision
value remained readable there. Automatic promotion and reconnect after
promotion need a separately configured failover regression.
13 changes: 13 additions & 0 deletions dpi_bridge/third_party/chunanyong_dm/PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@
in two schemas and reads both a foreign object and an array of those objects
from tables owned by the login user.

## Patch: discover standby for apply-wait read/write separation

- File: `zx.go`
- Some DM8 servers do not include the standby host and port in the login
response for `rwSeparate=4`. Query the primary's valid standby metadata in
that case, as the other read/write separation modes do, instead of dialing
`:0`. The apply-wait mode remains enabled on the connection.
- A failed metadata query may return no statement or rows. Close only handles
that exist before retrying the alternate metadata view.
- Regression: `scripts/verify_dm_ha.py` checks modes 0, 1, and 4 against a
real primary/standby pair, then writes `DECIMAL(30,8)` through mode 4 and
verifies the replicated value on the standby.

## Patch: resolve service names with the bridge's default port

- Files: `n.go`, `zzm.go`
Expand Down
12 changes: 9 additions & 3 deletions dpi_bridge/third_party/chunanyong_dm/zx.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ func (RWUtil rwUtil) chooseValidStandby(connection *DmConnection) (*ep, error) {
var err error
if connection.dmConnector.rwSeparate == RW_SEPARATE_USER_DEFINED {
return RWUtil.chooseStandbyUserDefined(connection), nil
} else if connection.dmConnector.rwSeparate == RW_SEPARATE_DB_APPLY_WAIT {
} else if connection.dmConnector.rwSeparate == RW_SEPARATE_DB_APPLY_WAIT &&
connection.StandbyHost != "" && connection.StandbyPort > 0 {
return newEP(connection.StandbyHost, connection.StandbyPort), nil
} else if connection.dmConnector.rwSeparate == RW_SEPARATE_EP_GROUP {
epStr := ""
Expand Down Expand Up @@ -200,8 +201,13 @@ func (RWUtil rwUtil) chooseValidStandby(connection *DmConnection) (*ep, error) {
}()

if err != nil {
rs.close()
stmt.close()
if rs != nil {
rs.close()
}
if stmt != nil {
stmt.close()
}
rs, stmt = nil, nil

if connection.Malini2 {
stmt, rs, err = connection.driverQuery(SQL_SELECT_STANDBY2 + filter)
Expand Down
97 changes: 97 additions & 0 deletions scripts/verify_dm_ha.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Verify read/write routing against a real DM primary and standby.

Set DM_HA_PRIMARY_HOST, DM_HA_STANDBY_HOST, DM_HA_USER, and DM_HA_PASSWORD.
The account must be able to query V$INSTANCE and create/drop a test table.
"""

from __future__ import annotations

import os
import time
import uuid
from decimal import Decimal

import dmPython


def connect(host: str, port: int, **options):
return dmPython.connect(
user=os.environ["DM_HA_USER"],
password=os.environ["DM_HA_PASSWORD"],
server=host,
port=port,
autoCommit=dmPython.DSQL_AUTOCOMMIT_ON,
login_timeout=2000,
**options,
)


def instance(conn):
with conn.cursor() as cur:
cur.execute("SELECT INSTANCE_NAME, MODE$, STATUS$ FROM V$INSTANCE")
return cur.fetchone()


def main():
primary_host = os.environ["DM_HA_PRIMARY_HOST"]
standby_host = os.environ["DM_HA_STANDBY_HOST"]
primary_port = int(os.environ.get("DM_HA_PRIMARY_PORT", "5236"))
standby_port = int(os.environ.get("DM_HA_STANDBY_PORT", "5236"))
amount = Decimal("12345678901234567890.12345678")
table = "DMPY_HA_" + uuid.uuid4().hex[:8].upper()

with connect(primary_host, primary_port) as primary, connect(
standby_host, standby_port
) as standby:
primary_name, primary_mode, primary_status = instance(primary)
standby_name, standby_mode, standby_status = instance(standby)
assert (primary_mode, primary_status) == ("PRIMARY", "OPEN")
assert (standby_mode, standby_status) == ("STANDBY", "OPEN")

for mode, expected in (
(dmPython.DSQL_RWSEPARATE_OFF, primary_name),
(dmPython.DSQL_RWSEPARATE_ON, standby_name),
(dmPython.DSQL_RWSEPARATE_ON2, standby_name),
):
with connect(
primary_host, primary_port, rwseparate=mode, rwseparate_percent=0
) as routed:
actual = instance(routed)[0]
assert actual == expected, (mode, actual, expected)
print(f"rwseparate={mode}: {actual}")

with primary.cursor() as cur:
cur.execute(f"CREATE TABLE {table} (ID INT, AMOUNT DECIMAL(30,8))")
try:
with connect(
primary_host,
primary_port,
rwseparate=dmPython.DSQL_RWSEPARATE_ON2,
rwseparate_percent=0,
) as routed:
with routed.cursor() as cur:
cur.execute(f"INSERT INTO {table} VALUES (?, ?)", (1, amount))

deadline = time.monotonic() + 10
while True:
try:
with standby.cursor() as cur:
cur.execute(f"SELECT CAST(AMOUNT AS VARCHAR(80)) FROM {table} WHERE ID=1")
row = cur.fetchone()
if row is not None:
assert Decimal(row[0]) == amount
print("standby replicated DECIMAL(30,8) exactly")
break
except dmPython.Error:
# The table may not be visible until the DDL reaches the standby.
pass
if time.monotonic() >= deadline:
raise AssertionError("write did not reach standby within 10 seconds")
time.sleep(0.2)
finally:
with primary.cursor() as cur:
cur.execute(f"DROP TABLE {table}")


if __name__ == "__main__":
main()
Loading