diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index e9dbf76..30542b9 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -19,6 +19,16 @@ jobs: with: python-version: "3.11" + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + cache-dependency-path: dpi_bridge/go.sum + + - name: Test Go bridge and patched driver + run: | + (cd dpi_bridge && go test ./...) + (cd dpi_bridge/third_party/chunanyong_dm && go test ./...) + - name: Install lint dependencies run: | python -m pip install --upgrade pip diff --git a/docs/api-reference.md b/docs/api-reference.md index bf2161d..b0194ca 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -60,6 +60,7 @@ dmPython.connect( - `host` 与 `server` 互斥(只允许设置一个)。 - `user` 支持 `user/password@server:port[/schema][?catalog=...]` 形式。 +- `login_timeout` 以秒为单位,限制首次建连握手;`connection_timeout` 以秒为单位,传给底层 TCP 拨号超时。两者均不限制 SQL 执行时间。 - 常量参数建议使用模块常量(如 `DSQL_AUTOCOMMIT_ON`、`ISO_LEVEL_READ_COMMITTED`)。 ### 模块函数 diff --git a/docs/test-results/2026-09-26-type-connection-matrix.md b/docs/test-results/2026-09-26-type-connection-matrix.md index 5f66644..f638862 100644 --- a/docs/test-results/2026-09-26-type-connection-matrix.md +++ b/docs/test-results/2026-09-26-type-connection-matrix.md @@ -20,7 +20,7 @@ ## 已发现的问题 -1. **连接选项的生效尚未验证**:`connection_timeout=5`、`login_timeout=5` 和 `app_name='dmpython_matrix'` 建连后,当前属性读回分别为 `0`、`0` 和空字符串。现有用例对前两项只验证“可建连”,不声称超时配置生效。需要单独设计可控的网络故障与服务器元数据检查。 +1. **连接选项旧问题**:此前 `connection_timeout=5`、`login_timeout=5` 和 `app_name='dmpython_matrix'` 建连后,属性读回分别为 `0`、`0` 和空字符串;`login_timeout=1` 遇到无响应服务端时,4 秒内仍无法返回。本轮已修复并加入行为回归。测试账号没有 `SYS.V$SESSIONS` 查询权限,尚未从服务端会话视图独立核实 `app_name`。 本次修复了 `datetime.time` 绑定在亚洲/上海本地时区下偏移五分钟的问题:桥接层曾以公元 0 年构造无时区的 `TIME`,触发历史时区偏移;改用现代锚定日期后,上述五版用例均验证 `23:59:58` 原样写入和读回。 @@ -32,6 +32,12 @@ 绑定带固定时区的 Python `datetime.time`、`datetime.datetime` 或带偏移量的文本时,保留偏移量;读取 `TIME WITH TIME ZONE` 和 `TIMESTAMP WITH TIME ZONE` 时也保留偏移量。回归以时间点相等为准,允许数据库把输入时区规范化为服务器时区。本机官方 DM8 上,Python 3.9、3.10、3.11、3.12、3.13 的完整真实库回归均为 **112 passed、0 failed**,另有 2 个非真实库用例未选入。 +## 后续修复:区间与连接超时 + +`INTERVAL DAY TO SECOND` 现可与 Python `datetime.timedelta` 往返,包括负数、微秒、零值和负 10 万天;读取时描述类型为 `dmPython.INTERVAL`。`INTERVAL YEAR TO MONTH` 的文本参数与读取类型 `dmPython.YEAR_MONTH_INTERVAL` 也已验证,两个区间类型的 `NULL` 往返通过。旧实现对大负区间读取发生 32 位整数溢出,本轮已修复。 + +`login_timeout` 现限制完整建连握手:本机假服务端接受 TCP 后不回复,设为 1 秒会在约 1 秒内报错。`connection_timeout` 以秒传递给底层 TCP 拨号选项,属性读回与输入一致;`app_name` 会传给底层驱动并可读回。本机官方 DM8 上,Python 3.9、3.10、3.11、3.12、3.13 的完整真实库回归均为 **121 passed、0 failed**,另有 2 个非真实库用例未选入。 + ## 下一轮边界 -需要继续覆盖其他十进制边界、时区边界、区间、复杂对象与数组、BFILE、不同编码,以及 SSL、UKey、MPP、读写分离和超时/故障转移的实际效果。当前仅有一版官方 DM8 服务端和一版 GitHub CI 开发镜像的历史基线;不能据此推断跨达梦服务端版本兼容。 +需要继续覆盖其他十进制边界、时区边界、其他区间限定形式、复杂对象与数组、BFILE、不同编码,以及 SSL、UKey、MPP、读写分离、拨号超时和故障转移的实际效果。当前仅有一版官方 DM8 服务端和一版 GitHub CI 开发镜像的历史基线;不能据此推断跨达梦服务端版本兼容。 diff --git a/dpi_bridge/dpi_conn.go b/dpi_bridge/dpi_conn.go index 710d3c5..34ad431 100644 --- a/dpi_bridge/dpi_conn.go +++ b/dpi_bridge/dpi_conn.go @@ -31,9 +31,11 @@ import ( "database/sql" "database/sql/driver" "fmt" + "net/url" "strconv" "strings" "sync" + "time" "unsafe" dm "gitee.com/chunanyong/dm" @@ -48,14 +50,15 @@ type connHandle struct { tx driver.Tx // active transaction (nil if none) // Connection parameters (set before login) - host string - port int - user string - password string - schema string - autocommit bool + host string + port int + user string + password string + schema string + autocommit bool loginTimeout int connTimeout int + appName string txnIsolation int // Post-login info @@ -128,11 +131,19 @@ func dpi_set_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, valLen C.sd conn.autocommit = (intVal != 0) // If already connected, apply autocommit if conn.conn != nil { - conn.conn.Exec("SET TRANSACTION AUTOCOMMIT " + map[bool]string{true: "ON", false: "OFF"}[conn.autocommit], nil) + conn.conn.Exec("SET TRANSACTION AUTOCOMMIT "+map[bool]string{true: "ON", false: "OFF"}[conn.autocommit], nil) } case DSQL_ATTR_LOGIN_TIMEOUT: + if intVal < 0 { + conn.lastErr = &diagInfo{errorCode: -1, message: "login timeout must be non-negative"} + return DSQL_ERROR + } conn.loginTimeout = intVal case DSQL_ATTR_CONNECTION_TIMEOUT: + if intVal < 0 { + conn.lastErr = &diagInfo{errorCode: -1, message: "connection timeout must be non-negative"} + return DSQL_ERROR + } conn.connTimeout = intVal case DSQL_ATTR_TXN_ISOLATION: conn.txnIsolation = intVal @@ -154,8 +165,15 @@ func dpi_set_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, valLen C.sd } else { conn.schema = C.GoString((*C.char)(val)) } - case DSQL_ATTR_APP_NAME, - DSQL_ATTR_SSL_PATH, DSQL_ATTR_SSL_PWD, + case DSQL_ATTR_APP_NAME: + if val == nil { + conn.appName = "" + } else if valLen > 0 { + conn.appName = C.GoStringN((*C.char)(val), C.int(valLen)) + } else { + conn.appName = C.GoString((*C.char)(val)) + } + case DSQL_ATTR_SSL_PATH, DSQL_ATTR_SSL_PWD, DSQL_ATTR_UKEY_NAME, DSQL_ATTR_UKEY_PIN, DSQL_ATTR_COMPRESS_MSG, DSQL_ATTR_USE_STMT_POOL, DSQL_ATTR_MPP_LOGIN, DSQL_ATTR_RWSEPARATE, @@ -213,6 +231,21 @@ func dpi_get_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, bufLen C.sd if valLen != nil { *valLen = 4 } + case DSQL_ATTR_LOGIN_TIMEOUT: + *(*C.sdint4)(val) = C.sdint4(conn.loginTimeout) + if valLen != nil { + *valLen = 4 + } + case DSQL_ATTR_CONNECTION_TIMEOUT: + *(*C.sdint4)(val) = C.sdint4(conn.connTimeout) + if valLen != nil { + *valLen = 4 + } + case DSQL_ATTR_APP_NAME: + n := cStringLen((*C.sdbyte)(val), int(bufLen), conn.appName) + if valLen != nil { + *valLen = C.sdint4(n) + } case DSQL_ATTR_CONNECTION_DEAD: dead := C.sdint4(0) // DSQL_CD_FALSE if conn.conn == nil { @@ -325,11 +358,11 @@ func dpi_login(hcon C.dhcon, svr *C.sdbyte, user *C.sdbyte, pwd *C.sdbyte) C.DPI } else { params = append(params, "autoCommit=false") } - if conn.loginTimeout > 0 { - params = append(params, fmt.Sprintf("loginTimeout=%d", conn.loginTimeout)) - } if conn.connTimeout > 0 { - params = append(params, fmt.Sprintf("socketTimeout=%d", conn.connTimeout*1000)) + params = append(params, fmt.Sprintf("socketTimeout=%d", conn.connTimeout)) + } + if conn.appName != "" { + params = append(params, "appName="+url.QueryEscape(conn.appName)) } if len(params) > 0 { dsn += "?" + strings.Join(params, "&") @@ -346,6 +379,11 @@ func dpi_login(hcon C.dhcon, svr *C.sdbyte, user *C.sdbyte, pwd *C.sdbyte) C.DPI // Force a real connection ctx := context.Background() + if conn.loginTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(conn.loginTimeout)*time.Second) + defer cancel() + } rawConn, dbErr := db.Conn(ctx) if dbErr != nil { db.Close() diff --git a/dpi_bridge/dpi_fetch.go b/dpi_bridge/dpi_fetch.go index 7702f98..fd06265 100644 --- a/dpi_bridge/dpi_fetch.go +++ b/dpi_bridge/dpi_fetch.go @@ -51,6 +51,17 @@ typedef struct { udbyte sign; udbyte val[DPI_MAX_NUMERIC_LEN]; } dpi_numeric_t; + +typedef struct { + int32_t interval_type; + int16_t interval_sign; + uint16_t reserved; + uint32_t day; + uint32_t hour; + uint32_t minute; + uint32_t second; + uint32_t fraction; +} dpi_interval_dt_t; */ import "C" import ( @@ -63,6 +74,8 @@ import ( "strings" "time" "unsafe" + + dm "gitee.com/chunanyong/dm" ) //export dpi_number_columns @@ -329,7 +342,10 @@ func dpi_fetch(hstmt C.dhstmt, rowNum *C.ulength) C.DPIRETURN { rowBind.actLenPtr = (*C.slength)(unsafe.Pointer(uintptr(unsafe.Pointer(bind.actLenPtr)) + uintptr(fetched)*ei.indStride)) } } - writeValueToBinding(row[colIdx-1], rowBind, stmt.columns[colIdx-1].sqlType) + if err := writeValueToBinding(row[colIdx-1], rowBind, stmt.columns[colIdx-1].sqlType); err != nil { + stmt.lastErr = diagFromError(err) + return DSQL_ERROR + } } stmt.fetchPos++ @@ -383,6 +399,8 @@ func cTypeSize(cType int16) uintptr { return unsafe.Sizeof(C.dpi_time_t{}) case DSQL_C_NUMERIC: return 19 // DPI_MAX_NUMERIC_LEN(16) + precision + scale + sign + case DSQL_C_INTERVAL_DAY_TO_SECOND: + return unsafe.Sizeof(C.dpi_interval_dt_t{}) default: return 0 // variable-length (string, binary, etc.) } @@ -430,7 +448,10 @@ func dpi_get_data(hstmt C.dhstmt, icol C.udint2, ctype C.sdint2, sqlType = stmt.columns[idx].sqlType } - writeValueToBinding(rawVal, bind, sqlType) + if err := writeValueToBinding(rawVal, bind, sqlType); err != nil { + stmt.lastErr = diagFromError(err) + return DSQL_ERROR + } return DSQL_SUCCESS } @@ -494,12 +515,12 @@ func dpi_row_count(hstmt C.dhstmt, rowNum *C.sdint8) C.DPIRETURN { } // writeValueToBinding writes a Go value into a C buffer according to the binding info. -func writeValueToBinding(val interface{}, bind bindColInfo, sqlType int16) { +func writeValueToBinding(val interface{}, bind bindColInfo, sqlType int16) error { if val == nil { if bind.indPtr != nil { *bind.indPtr = C.slength(DSQL_NULL_DATA) } - return + return nil } if bind.dataPtr == nil { @@ -508,7 +529,7 @@ func writeValueToBinding(val interface{}, bind bindColInfo, sqlType int16) { if bind.indPtr != nil { *bind.indPtr = C.slength(len(s)) } - return + return nil } cType := bind.cType @@ -554,12 +575,45 @@ func writeValueToBinding(val interface{}, bind bindColInfo, sqlType int16) { writeTimeValue(val, bind) case DSQL_C_NUMERIC: writeNumericValue(val, bind) + case DSQL_C_INTERVAL_DAY_TO_SECOND: + return writeIntervalDaySecondValue(val, bind) case DSQL_C_LOB_HANDLE: writeLobHandleValue(val, bind, sqlType) default: // Default: treat as string writeStringValue(val, bind) } + return nil +} + +func writeIntervalDaySecondValue(val interface{}, bind bindColInfo) error { + if bind.bufLen < int64(unsafe.Sizeof(C.dpi_interval_dt_t{})) { + return fmt.Errorf("interval binding buffer is too small") + } + value := fmt.Sprint(val) + interval, err := dm.NewDmIntervalDTByString(value) + if err != nil { + return fmt.Errorf("invalid day-second interval %q: %w", value, err) + } + data := (*C.dpi_interval_dt_t)(bind.dataPtr) + data.interval_type = 10 // DSQL_IS_DAY_TO_SECOND + data.interval_sign = 0 + part := strings.TrimSpace(strings.TrimPrefix(strings.ToUpper(value), "INTERVAL")) + if strings.HasPrefix(part, "-") || strings.HasPrefix(part, "'-") { + data.interval_sign = 1 + } + data.day = C.uint32_t(interval.GetDay()) + data.hour = C.uint32_t(interval.GetHour()) + data.minute = C.uint32_t(interval.GetMinute()) + data.second = C.uint32_t(interval.GetSecond()) + data.fraction = C.uint32_t(interval.GetMsec() * 1000) + if bind.indPtr != nil { + *bind.indPtr = C.slength(unsafe.Sizeof(C.dpi_interval_dt_t{})) + } + if bind.actLenPtr != nil { + *bind.actLenPtr = C.slength(unsafe.Sizeof(C.dpi_interval_dt_t{})) + } + return nil } func writeStringValue(val interface{}, bind bindColInfo) { @@ -1139,6 +1193,15 @@ func extractBoundValue(bind bindParamInfo) interface{} { case DSQL_C_NUMERIC: num := (*C.dpi_numeric_t)(bind.dataPtr) return numericToString(num) + case DSQL_C_INTERVAL_DAY_TO_SECOND: + interval := (*C.dpi_interval_dt_t)(bind.dataPtr) + sign := "" + if interval.interval_sign != 0 { + sign = "-" + } + return fmt.Sprintf("INTERVAL '%s%d %02d:%02d:%02d.%06d' DAY(9) TO SECOND(6)", + sign, interval.day, interval.hour, interval.minute, + interval.second, interval.fraction/1000) case DSQL_C_LOB_HANDLE: hlob := *(*C.dhloblctr)(bind.dataPtr) if hlob == nil { diff --git a/dpi_bridge/dpi_stmt.go b/dpi_bridge/dpi_stmt.go index 4768582..ed0f15d 100644 --- a/dpi_bridge/dpi_stmt.go +++ b/dpi_bridge/dpi_stmt.go @@ -678,6 +678,10 @@ func mapGoTypeToDPI(ct *sql.ColumnType) (sqlType int16, precision uint64, scale return DSQL_TIMESTAMP, 26, 6, 26 case "TIMESTAMP WITH TIME ZONE", "DATETIME WITH TIME ZONE": return DSQL_TIMESTAMP_TZ, 34, 6, 34 + case "INTERVAL DAY TO SECOND": + return DSQL_INTERVAL_DAY_TO_SECOND, 0, 6, 64 + case "INTERVAL YEAR TO MONTH": + return DSQL_INTERVAL_YEAR_TO_MONTH, 0, 0, 64 case "BIT", "BOOL", "BOOLEAN": return DSQL_BIT, 1, 0, 1 case "BINARY": diff --git a/dpi_bridge/third_party/chunanyong_dm/PATCHES.md b/dpi_bridge/third_party/chunanyong_dm/PATCHES.md index 4c8edcd..58ad78e 100644 --- a/dpi_bridge/third_party/chunanyong_dm/PATCHES.md +++ b/dpi_bridge/third_party/chunanyong_dm/PATCHES.md @@ -31,6 +31,28 @@ - `test_clob_unicode_problem_patterns_length_contract` - `test_clob_unicode_problem_patterns_subprocess_no_crash` +## Patch: initial connection timeout through endpoint groups + +- Files: `a.go`, `n.go`, `x.go`, `y.go`, `m.go` +- Problem: ordinary host connections are wrapped in an endpoint group. That path + replaced the caller's context with `context.Background()`, so `login_timeout` + could not stop an unresponsive handshake. +- Fix: pass the original context through endpoint selection and dialing, apply + its deadline to the socket during handshake, then clear the deadline once the + connection is established. Endpoint retries and wait intervals also respect + cancellation. Reconnects retain a fresh background context. +- Regression: `test_login_timeout_interrupts_unresponsive_handshake` uses a + local TCP listener that accepts a connection but never replies. + +## Patch: application name query value + +- File: `n.go` +- Decode the escaped `appName` query value so spaces, `&`, and `+` reach the + driver as one application name. Other existing DSN properties retain their + previous parsing behavior. +- Regression: `test_connection_timeout_options_are_reported` connects with an + application name containing `&` and `+`. + ## Rollback - Remove `replace gitee.com/chunanyong/dm => ./third_party/chunanyong_dm` in `dpi_bridge/go.mod`. diff --git a/dpi_bridge/third_party/chunanyong_dm/a.go b/dpi_bridge/third_party/chunanyong_dm/a.go index e22ca80..8a631f2 100644 --- a/dpi_bridge/third_party/chunanyong_dm/a.go +++ b/dpi_bridge/third_party/chunanyong_dm/a.go @@ -47,11 +47,17 @@ func dm_build_426(dm_build_427 context.Context, dm_build_428 *DmConnection) (*dm if dm_build_432 { dm_build_429, dm_build_430 = dm_build_431(dm_build_427, dm_build_428.dmConnector.host+":"+strconv.Itoa(int(dm_build_428.dmConnector.port))) } else { - dm_build_429, dm_build_430 = dm_build_434(dm_build_428.dmConnector.host+":"+strconv.Itoa(int(dm_build_428.dmConnector.port)), time.Duration(dm_build_428.dmConnector.socketTimeout)*time.Second) + dm_build_429, dm_build_430 = dm_build_434(dm_build_427, dm_build_428.dmConnector.host+":"+strconv.Itoa(int(dm_build_428.dmConnector.port)), time.Duration(dm_build_428.dmConnector.socketTimeout)*time.Second) } if dm_build_430 != nil { return nil, dm_build_430 } + if deadline, ok := dm_build_427.Deadline(); ok { + if err := dm_build_429.SetDeadline(deadline); err != nil { + dm_build_429.Close() + return nil, err + } + } dm_build_433 := dm_build_414{} dm_build_433.dm_build_415 = dm_build_429 @@ -67,8 +73,8 @@ func dm_build_426(dm_build_427 context.Context, dm_build_428 *DmConnection) (*dm return &dm_build_433, nil } -func dm_build_434(dm_build_435 string, dm_build_436 time.Duration) (net.Conn, error) { - dm_build_437, dm_build_438 := net.DialTimeout("tcp", dm_build_435, dm_build_436) +func dm_build_434(ctx context.Context, dm_build_435 string, dm_build_436 time.Duration) (net.Conn, error) { + dm_build_437, dm_build_438 := (&net.Dialer{Timeout: dm_build_436}).DialContext(ctx, "tcp", dm_build_435) if dm_build_438 != nil { return &net.TCPConn{}, ECGO_COMMUNITION_ERROR.addDetail("\tdial address: " + dm_build_435).throw() } diff --git a/dpi_bridge/third_party/chunanyong_dm/m.go b/dpi_bridge/third_party/chunanyong_dm/m.go index 9f0be6e..8bc00be 100644 --- a/dpi_bridge/third_party/chunanyong_dm/m.go +++ b/dpi_bridge/third_party/chunanyong_dm/m.go @@ -457,7 +457,7 @@ func (dc *DmConnection) reconnect() error { var newConn *DmConnection if dc.dmConnector.group != nil { - if newConn, err = dc.dmConnector.group.connect(dc.dmConnector); err != nil { + if newConn, err = dc.dmConnector.group.connect(dc.dmConnector, context.Background()); err != nil { return err } } else { diff --git a/dpi_bridge/third_party/chunanyong_dm/n.go b/dpi_bridge/third_party/chunanyong_dm/n.go index a0a77e5..6be0e0e 100644 --- a/dpi_bridge/third_party/chunanyong_dm/n.go +++ b/dpi_bridge/third_party/chunanyong_dm/n.go @@ -759,7 +759,15 @@ func (c *DmConnector) parseDSN(dsn string) (*Properties, string, string, error) for _, kvString := range strings.Split(queryString, "&") { kv := strings.SplitN(kvString, "=", 2) if kv != nil && len(kv) > 1 { - dsnProps.Set(kv[0], kv[1]) + value := kv[1] + if kv[0] == AppNameKey { + decoded, err := url.QueryUnescape(value) + if err != nil { + return nil, "", "", err + } + value = decoded + } + dsnProps.Set(kv[0], value) } } } @@ -927,7 +935,7 @@ func (c *DmConnector) Driver() driver.Driver { func (c *DmConnector) connect(ctx context.Context) (*DmConnection, error) { if c.group != nil && len(c.group.epList) > 0 { - return c.group.connect(c) + return c.group.connect(c, ctx) } else { return c.connectSingle(ctx) } @@ -980,6 +988,12 @@ func (c *DmConnector) connectSingle(ctx context.Context) (*DmConnection, error) return nil, err } } + if _, ok := ctx.Deadline(); ok { + if err = dc.Access.dm_build_415.SetDeadline(time.Time{}); err != nil { + dc.cleanup() + return nil, err + } + } return dc, nil } diff --git a/dpi_bridge/third_party/chunanyong_dm/n_test.go b/dpi_bridge/third_party/chunanyong_dm/n_test.go new file mode 100644 index 0000000..dfee99c --- /dev/null +++ b/dpi_bridge/third_party/chunanyong_dm/n_test.go @@ -0,0 +1,16 @@ +package dm + +import "testing" + +func TestParseDSNDecodesApplicationName(t *testing.T) { + connector := new(DmConnector).init() + props, _, _, err := connector.parseDSN( + "dm://user:pass@127.0.0.1:5236?appName=dmpython+%26+matrix%2B1", + ) + if err != nil { + t.Fatal(err) + } + if got := props.GetString(AppNameKey, ""); got != "dmpython & matrix+1" { + t.Fatalf("application name = %q", got) + } +} diff --git a/dpi_bridge/third_party/chunanyong_dm/x.go b/dpi_bridge/third_party/chunanyong_dm/x.go index b2f6ebf..8204b6e 100644 --- a/dpi_bridge/third_party/chunanyong_dm/x.go +++ b/dpi_bridge/third_party/chunanyong_dm/x.go @@ -152,10 +152,10 @@ func (ep *ep) refreshStatus(alive bool, conn *DmConnection) { } } -func (ep *ep) connect(connector *DmConnector) (*DmConnection, error) { +func (ep *ep) connect(connector *DmConnector, ctx context.Context) (*DmConnection, error) { connector.host = ep.host connector.port = ep.port - conn, err := connector.connectSingle(context.Background()) + conn, err := connector.connectSingle(ctx) if err != nil { ep.refreshStatus(false, conn) return nil, err diff --git a/dpi_bridge/third_party/chunanyong_dm/y.go b/dpi_bridge/third_party/chunanyong_dm/y.go index dea32fc..1050bc9 100644 --- a/dpi_bridge/third_party/chunanyong_dm/y.go +++ b/dpi_bridge/third_party/chunanyong_dm/y.go @@ -7,6 +7,7 @@ package dm import ( "bytes" + "context" "math/rand" "sync" "time" @@ -48,7 +49,7 @@ func newEPGroup(name string, serverList []*ep) *epGroup { return g } -func (g *epGroup) connect(connector *DmConnector) (*DmConnection, error) { +func (g *epGroup) connect(connector *DmConnector, ctx context.Context) (*DmConnection, error) { var dbSelector = g.getEPSelector(connector) var ex error = nil // 如果配置了loginMode的主、备等优先策略,而未找到最高优先级的节点时持续循环switchtimes次,如果最终还是没有找到最高优先级则选择次优先级的 @@ -60,11 +61,18 @@ func (g *epGroup) connect(connector *DmConnector) (*DmConnection, error) { cycleCount = connector.switchTimes + 1 } for i := int32(0); i < cycleCount; i++ { + if err := ctx.Err(); err != nil { + return nil, err + } // 循环了一遍,如果没有符合要求的, 重新排序, 再尝试连接 - conn, err := g.traverseServerList(connector, dbSelector, i == 0, i == cycleCount-1) + conn, err := g.traverseServerList(connector, dbSelector, i == 0, i == cycleCount-1, ctx) if err != nil { ex = err - time.Sleep(time.Duration(connector.switchInterval) * time.Millisecond) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(connector.switchInterval) * time.Millisecond): + } continue } return conn, nil @@ -97,12 +105,15 @@ func (g *epGroup) getEPSelector(connector *DmConnector) *epSelector { * DBError.ECJDBC_INVALID_SERVER_MODE 有站点的模式不匹配 * DBError.ECJDBC_COMMUNITION_ERROR 所有站点都连不上 */ -func (g *epGroup) traverseServerList(connector *DmConnector, epSelector *epSelector, first bool, last bool) (*DmConnection, error) { +func (g *epGroup) traverseServerList(connector *DmConnector, epSelector *epSelector, first bool, last bool, ctx context.Context) (*DmConnection, error) { epList := epSelector.sortDBList(first) errorMsg := bytes.NewBufferString("") var ex error = nil // 第一个错误 for _, server := range epList { - conn, err := server.connect(connector) + if err := ctx.Err(); err != nil { + return nil, err + } + conn, err := server.connect(connector, ctx) if err != nil { if ex == nil { ex = err diff --git a/dpi_bridge/types.go b/dpi_bridge/types.go index 0a61ba1..7c5c551 100644 --- a/dpi_bridge/types.go +++ b/dpi_bridge/types.go @@ -193,6 +193,8 @@ const ( DSQL_ROWID = 28 DSQL_RSET = 119 DSQL_BFILE = 1000 + DSQL_INTERVAL_YEAR_TO_MONTH = 106 + DSQL_INTERVAL_DAY_TO_SECOND = 109 ) // Environment/Connection attributes (from DPIext.h) diff --git a/src/native/vInterval.c b/src/native/vInterval.c index 8eaaf71..5d1efd3 100644 --- a/src/native/vInterval.c +++ b/src/native/vInterval.c @@ -174,7 +174,7 @@ IntervalVar_SetValue( //delta´«½øÀ´ʱÊÇÒѾ­»»Ëã³ɸ÷¸öµ¥λÄܱíʾµÄ×î´óֵÁˣ¬dayÈç¹ûΪ¸º£¬Õû¸öµĽá¹û×îÖձØΪ¸ºµÄ if (days < 0) { - total_seconds = delta->days * 24 * 3600 + delta->seconds; + total_seconds = (sdint8) delta->days * 24 * 3600 + delta->seconds; if (total_seconds < 0 && delta->microseconds > 0) { @@ -243,7 +243,7 @@ IntervalVar_GetValue( if (ds->interval_sign == 1) { //intvÉϵÄday,hour,minute,seconds¶¼ÊÇÕýֵ£¬signÓÃÀ´Çø·ÖÕý¸º - total_seconds = days * 24 * 60 * 60 + seconds; //ÓÃÕýֵ±íʾ¸ºµÄ×ÜÃëÊý + total_seconds = (sdint8) days * 24 * 60 * 60 + seconds; //ÓÃÕýֵ±íʾ¸ºµÄ×ÜÃëÊý //microsecondsת³ÉÕýֵ microseconds = 1000000 - microseconds; //ǰÃæËã³öÀ´µÄ΢ÃëÊÇÕýֵ£¬µ«sign=1ʱ±íʾµÄÊǸº¶àÉÙ΢Ã룬ת³ÉÕýֵ @@ -294,6 +294,13 @@ YMIntervalVar_SetValue( if (dmBuffer_FromObject(&buffer, value, var->environment->encoding) < 0) return -1; + if (buffer.size > var->bufferSize) + { + dmBuffer_Clear(&buffer); + PyErr_SetString(PyExc_ValueError, "year-month interval exceeds the binding buffer"); + return -1; + } + if (buffer.size) { memcpy(var->data + var->bufferSize * pos, buffer.ptr, buffer.size); diff --git a/tests/integration/test_p1_connection_matrix.py b/tests/integration/test_p1_connection_matrix.py index 6d3f970..8b8a754 100644 --- a/tests/integration/test_p1_connection_matrix.py +++ b/tests/integration/test_p1_connection_matrix.py @@ -2,6 +2,11 @@ from __future__ import annotations +import os +import socket +import subprocess +import sys + import pytest import dmPython @@ -101,3 +106,57 @@ def test_optional_setting_connects_and_queries(conn_params, option): with conn.cursor() as cur: cur.execute("SELECT 1") assert cur.fetchone() == (1,) + + +def test_connection_timeout_options_are_reported(conn_params): + with dmPython.connect( + **conn_params, + login_timeout=2, + connection_timeout=2, + app_name="dmpython & matrix+1", + ) as conn: + assert conn.login_timeout == 2 + assert conn.connection_timeout == 2 + assert conn.app_name == "dmpython & matrix+1" + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) + + +def test_login_timeout_interrupts_unresponsive_handshake(): + code = """ +import dmPython +import sys +import time + +start = time.monotonic() +try: + dmPython.connect(user="probe", password="probe", server="127.0.0.1", + port=int(sys.argv[1]), login_timeout=1) +except dmPython.Error: + print(time.monotonic() - start) +else: + raise AssertionError("unresponsive server accepted a connection") +""" + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + listener.settimeout(4) + child = subprocess.Popen( + [sys.executable, "-c", code, str(listener.getsockname()[1])], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=os.environ.copy(), + ) + try: + peer, _ = listener.accept() + with peer: + stdout, stderr = child.communicate(timeout=4) + finally: + if child.poll() is None: + child.kill() + child.communicate() + + assert child.returncode == 0, stderr + assert float(stdout.strip()) < 3 diff --git a/tests/integration/test_p1_type_matrix.py b/tests/integration/test_p1_type_matrix.py index 41863d5..c120842 100644 --- a/tests/integration/test_p1_type_matrix.py +++ b/tests/integration/test_p1_type_matrix.py @@ -3,10 +3,13 @@ from __future__ import annotations import datetime as dt +import re from decimal import Decimal import pytest +import dmPython + pytestmark = [pytest.mark.requires_dm, pytest.mark.p1_contract] @@ -64,7 +67,14 @@ def test_scalar_type_roundtrip(conn, table_name_factory, drop_table, sql_type, v cur.close() -@pytest.mark.parametrize("sql_type", ["INTEGER", "DECIMAL(18, 6)", "VARCHAR(80)", "VARBINARY(16)", "DATE", "TIMESTAMP"]) +@pytest.mark.parametrize( + "sql_type", + [ + "INTEGER", "DECIMAL(18, 6)", "VARCHAR(80)", "VARBINARY(16)", + "DATE", "TIMESTAMP", "INTERVAL DAY(9) TO SECOND(6)", + "INTERVAL YEAR(9) TO MONTH", + ], +) def test_typed_null_roundtrip(conn, table_name_factory, drop_table, sql_type): table = table_name_factory("DMPY_NULL") cur = conn.cursor() @@ -164,3 +174,45 @@ def test_timezone_time_roundtrip_preserves_instant( drop_table(cur, table) conn.commit() cur.close() + + +@pytest.mark.parametrize( + "value", + [ + dt.timedelta(days=1, hours=2, minutes=3, seconds=4, microseconds=123456), + -dt.timedelta(seconds=1, microseconds=1), + -dt.timedelta(days=100000, microseconds=1), + dt.timedelta(0), + ], + ids=["positive-fraction", "negative-fraction", "large-negative", "zero"], +) +def test_day_second_interval_timedelta_roundtrip(conn, table_name_factory, drop_table, value): + table = table_name_factory("DMPY_INTERVAL_DT") + cur = conn.cursor() + try: + cur.execute(f"CREATE TABLE {table} (v INTERVAL DAY(9) TO SECOND(6))") + cur.execute(f"INSERT INTO {table} VALUES (?)", (value,)) + conn.commit() + cur.execute(f"SELECT v FROM {table}") + assert cur.description[0][1] is dmPython.INTERVAL + assert cur.fetchone() == (value,) + finally: + drop_table(cur, table) + conn.commit() + cur.close() + + +def test_year_month_interval_text_roundtrip(conn, table_name_factory, drop_table): + table = table_name_factory("DMPY_INTERVAL_YM") + cur = conn.cursor() + try: + cur.execute(f"CREATE TABLE {table} (v INTERVAL YEAR(9) TO MONTH)") + cur.execute(f"INSERT INTO {table} VALUES (?)", ("INTERVAL '01-02' YEAR(9) TO MONTH",)) + conn.commit() + cur.execute(f"SELECT v FROM {table}") + assert cur.description[0][1] is dmPython.YEAR_MONTH_INTERVAL + assert re.search(r"'0*1-0*2'", cur.fetchone()[0]) + finally: + drop_table(cur, table) + conn.commit() + cur.close()