CAS Protocol Reference¶
Technical documentation for the CUBRID CAS (Common Application Server) wire protocol as implemented by pycubrid.
New to CAS? CAS Architecture and Protocol Concepts introduces the Broker, CAS process and CCI and maps DB-API calls to function codes before this reference.
Table of Contents¶
- Overview
- Architecture
- Packet Framing
- Connection Handshake
- Request/Response Lifecycle
- Function Codes
- Packet Classes
- ClientInfoExchangePacket
- OpenDatabasePacket
- Connection charset
- PrepareAndExecutePacket
- PreparePacket
- ExecutePacket
- FetchPacket
- CommitPacket
- RollbackPacket
- CloseDatabasePacket
- CloseQueryPacket
- GetEngineVersionPacket
- GetSchemaPacket
- BatchExecutePacket
- ExecuteArrayPacket
- LOBNewPacket
- LOBWritePacket
- LOBReadPacket
- GetLastInsertIdPacket
- GetDbParameterPacket / SetDbParameterPacket
- PacketWriter
- PacketReader
- Data Types on the Wire
- Error Handling
- Constants Reference
Overview¶
CUBRID uses a proprietary binary protocol called CAS (Common Application Server) for client–server communication. The protocol operates over TCP sockets with big-endian byte ordering.
Key characteristics:
- Transport: TCP (default port 33000 for the broker)
- Byte order: Big-endian (network byte order)
- Framing: 4-byte data length + 4-byte CAS info + payload
- Handshake: Magic string
CUBRK(plaintext) orCUBRS(STARTTLS) + client type + protocol version - TLS (optional): STARTTLS-style upgrade after the handshake using
start_tls()/wrap_socket()beforeOPEN_DATABASE - Session: Stateful — session ID maintained after
OpenDatabase
Architecture¶
graph LR
client["Client (pycubrid)"] -- TCP port 33000 --> broker[Broker]
broker --> cas[CAS Process]
cas --> db[CUBRID Database]
- Client connects to the Broker on port 33000
- Broker performs handshake and may redirect to a CAS process on a different port
- Client opens a database session on the CAS process
- All subsequent requests go through the CAS session
Packet Framing¶
All packets after the initial handshake use the following frame format:
graph LR
data["Data Length (4B) big-endian int32"] --> casinfo["CAS Info (4B) session state"]
casinfo --> payload["Payload (variable) function-specific"]
| Field | Size | Description |
|---|---|---|
| Data Length | 4 bytes | Length of CAS Info + Payload (signed int32, big-endian) |
| CAS Info | 4 bytes | Session state bytes maintained by the server |
| Payload | variable | Function code + arguments (requests) or response data |
The first CAS Info byte is transaction state: 0 means OUT_TRAN and 1
means IN_TRAN. OUT_TRAN after END_TRAN or an auto-committing request does
not mean the socket or CAS session was released; send the next request on
the same transport. The CAS may still close the socket after an OUT_TRAN reply
(memory restart, cubrid broker reset, CHANGE CLIENT), so before the next
request the driver sends CHECK_CAS (FC=32), like JDBC
UClientSideConnection.checkReconnect. Only a failed probe replaces the session,
once per request, before that request is first sent (#485). A failed CHECK_CAS
may also justify explicit ping(reconnect=True) recovery, but an uncertain
application request is never replayed automatically. Commit and rollback send
CLOSE_REQ (FC=6) for open cursor query handles before END_TRAN.
Deferred close (#488): when a direct CUBRID CAS reports statement pooling in
OPEN_DATABASE (broker_info[0] == 1 and broker_info[2] == 1), query handles survive END_TRAN, so in
autocommit mode a handle released by cursor.close() or by re-executing a cursor,
and in either mode the handle of a cursor collected without close(), is not
closed with its own CLOSE_REQ. Its id is appended to the next FC41 request as
extra prepare arguments after the auto-commit flag (the prepare argument count
grows by one per id), and CAS frees those handles before preparing the statement.
This is the wire mechanism of JDBC's deferred close, but the policy differs: JDBC
defers only statements without a result set and closes SELECT/CALL/EVALUATE
handles at once (CLOSE_USTATEMENT), while pycubrid defers result-set handles too.
A native error in that statement still frees them. One statement carries at most
256 ids; an explicit release while 256 are queued sends CLOSE_REQ at once, and
collected cursors are always queued and ride on later statements. FC20 batch
requests do not carry queued IDs: a handle released by executemany() waits
for a later FC41 or transaction boundary. Empty executemany() sends no batch
SQL and does not flush the queue; when its previous handle can be deferred, it
also sends no CLOSE_REQ. Existing immediate-close rules still apply when
deferral is unavailable (including a full queue). commit() and
rollback() close every id still queued for the session with CLOSE_REQ before
END_TRAN, as they closed unreferenced cursors before. Queued ids
belong to one physical session: they are dropped when it is retired or replaced
and never sent to another. Without statement pooling CAS frees handles at every
commit, so CLOSE_REQ is sent at once as before and a collected cursor's handle is
left to the next commit. A shard proxy (broker_info[0] other than 1, CUBRID)
ignores the extra arguments, so explicit releases still send CLOSE_REQ at once.
Collected proxy cursor handles rely on proxy transaction/session cleanup. Explicit releases
during session setup (the escape-mode probe and restored settings of a replacement
session) are never deferred; a cursor collected on an eligible direct, pooling-enabled
session may queue its handle during setup, since it cannot send anything.
Each handle keeps the generation of the session that opened it, so a cursor collected
after a reconnect cannot release a handle id on the new session.
Pooling-off ownership (#584): a direct CUBRID CAS with statement pooling
explicitly disabled frees all ordinary cursor and schema handles at a transaction
boundary, and can immediately reuse their IDs on the same physical session.
The driver retires ownership when the actual reply is OUT_TRAN for END_TRAN,
an autocommitting FC41/FC3 or version request, a failed autocommit FC2 which
auto-rolls back, or the final ordinary autocommit FETCH. Successful FC2 prepare
does not establish a boundary. This runs before parsing the reply and before an
INSERT's identity RPC:
that subsequent RPC can return IN_TRAN although the preceding commit freed the
handles. The current FC41 result is not adopted when its reply already freed it,
including a complete-reply DataError. Buffered rows remain readable; a required
FETCH from an unfinished invalidated result raises InterfaceError, while a
completed result retains normal EOF. Physical generation and session verification
are unchanged. OUT_TRAN echoes from CHECK_CAS, CLOSE_REQ, parameters, schema
requests/fetches and batch replies do not independently prove this boundary;
manual FETCH and pooling-enabled/proxy sessions keep their existing behavior.
Automatic no_backslash_escapes detection is scoped to a physical session:
new sessions are probed before parameter binding resumes, while a healthy
same-session CHECK_CAS does not probe. Explicit mode remains pinned. If
recovery probing fails, the replacement is retired and ping returns False;
async SQL bound under a prior session generation is rejected before send.
Building a header:
from pycubrid.packet import build_protocol_header
header = build_protocol_header(data_length=42, cas_info=b"\x00\x00\x00\x00")
# Returns 8 bytes: 4-byte length + 4-byte cas_info
Exceptions¶
The handshake packet (ClientInfoExchangePacket) does NOT use this framing — it sends raw bytes without a length/cas_info header.
The open database packet (OpenDatabasePacket) sends raw bytes (628 bytes) without a header, but its response uses standard framing.
Connection Handshake¶
The connection flow has up to four phases (the TLS phase is optional):
Phase 1: Client Info Exchange¶
sequenceDiagram
participant Client
participant Broker
alt ssl requested
Client->>Broker: ClientInfoExchange raw 10B ("CUBRS", CLIENT_JDBC=3, CAS_VER=0x48, padding)
else plaintext
Client->>Broker: ClientInfoExchange raw 10B ("CUBRK", CLIENT_JDBC=3, CAS_VER=0x48, padding)
end
Broker-->>Client: status int32 (0 ok / >0 redirect port / <0 fail-fast)
- Magic string:
"CUBRK"(plaintext) or"CUBRS"(STARTTLS-requested) — 5 ASCII bytes - Client type:
CLIENT_JDBC = 3(pycubrid identifies as a JDBC-compatible client) - CAS version:
PROTO_INDICATOR(0x40) | VERSION(8) = 0x48 - Status int32: signed big-endian
> 0— redirect port: close socket, reconnect to(host, status), do not repeat the handshake (mirrors the JDBCBrokerHandler.connectBrokerbehavior). A status above65535is not a TCP port and raisesOperationalErrorin both drivers before any reconnect (#896)< 0— fail-fast: raiseOperationalError(status)== 0— reuse the current socket (direct mode)
Phase 1.5: TLS Upgrade (optional)¶
If ssl was truthy on the connect call, the live transport is upgraded before any
OPEN_DATABASE bytes are written:
- Sync driver:
ssl.SSLContext.wrap_socket(sock, server_hostname=host) - Async driver:
loop.start_tls(transport, protocol, context, server_hostname=host, ssl_handshake_timeout=...)
The TLS handshake uses read_timeout when configured, otherwise a 10-second
default. That default does not bound subsequent requests; connect_timeout
still bounds only the TCP connect.
Failed handshakes abort the transport.
Phase 2: Open Database¶
sequenceDiagram
participant Client
participant CAS
Client->>CAS: OpenDatabase raw 628B (database 32B, user 32B, password 32B, extended 512B, reserved 20B)
CAS-->>Client: Framed response (CAS Info 4B, Response Code 4B, Broker Info 8B, Session ID 4B)
- Database, user, and password are fixed-length null-padded strings
- Broker Info (8 bytes) contains:
- Byte 0:
db_type - Byte 2:
statement_pooling - Byte 4:
protocol_version(lower 6 bits) - Session ID: Used for subsequent CAS info tracking
Phase 3: Ready¶
After successful OpenDatabase, the connection is ready for SQL operations.
Request/Response Lifecycle¶
Every operation after connection follows this pattern:
sequenceDiagram
participant Client
participant Server
Client->>Client: Build payload [function_code (1B)] [arguments...]
Client->>Server: Send [data_length (4B)] [cas_info (4B)] [payload]
Server-->>Client: Response [data_length (4B)] [cas_info (4B)] [response_code (4B)] [result_data...]
alt response_code >= 0
Client->>Client: Success path (code may carry additional info)
else response_code < 0
Client->>Client: Error path (error_code + error_message)
end
For the explicit sync pycubrid.compat.native prepared cursor, the synchronous
transport accepts a private physical-session generation expectation (#478).
Only calls that pass this opt-in value are fenced; the #439 owner stamps
each FC2/FC3/FC6/FC8 request. The transport checks generation and socket identity before
serialization, immediately before send and again around the response, under
the same reentrant lock that covers connect, ping recovery, close and a whole
request/response. A captured request socket is used for both send and receive,
so a reentrant replacement cannot redirect old FC3/FC6 bytes or read the new
session's reply as success. A definitely pre-byte local validation failure
keeps the session; an interrupted/partial prepared send or uncertain reply
retires it without replay or a new-session FC6. A fully received broker SQL
error retains the existing code-based DB-API mapping and a usable transport;
the explicit compatibility cursor redacts broker-controlled error text while
preserving its class, code, errno and SQLSTATE. The fence does not change
ordinary FC41 calls or raise
threadsafety above 1; a long blocking request can delay ping on the same
connection, and other ordinary multi-step cursor/schema lifecycles are not
made generally thread-safe by this lock. Calling DB-API methods reentrantly
from an arbitrary Python signal handler is not a supported safety contract.
Function Codes¶
All 41 CAS function codes, defined in CASFunctionCode:
| Code | Name | Description |
|---|---|---|
| 1 | END_TRAN |
Commit or rollback a transaction |
| 2 | PREPARE |
Prepare a SQL statement |
| 3 | EXECUTE |
Execute a prepared statement |
| 4 | GET_DB_PARAMETER |
Get a database parameter value |
| 5 | SET_DB_PARAMETER |
Set a database parameter value |
| 6 | CLOSE_REQ_HANDLE |
Close a query/request handle |
| 7 | CURSOR |
Position the cursor |
| 8 | FETCH |
Fetch result rows |
| 9 | SCHEMA_INFO |
Get schema information |
| 10 | OID_GET |
Get object by OID |
| 11 | OID_PUT |
Update object by OID |
| 15 | GET_DB_VERSION |
Get the database engine version |
| 16 | GET_CLASS_NUM_OBJS |
Get class object count |
| 17 | OID_CMD |
OID operations (drop, lock, etc.) |
| 18 | COLLECTION |
Collection operations |
| 19 | NEXT_RESULT |
Get next result set |
| 20 | EXECUTE_BATCH |
Batch execute multiple SQL statements |
| 21 | EXECUTE_ARRAY |
Execute with array binding |
| 22 | CURSOR_UPDATE |
Update via cursor |
| 23 | GET_ATTR_TYPE_STR |
Get attribute type string |
| 24 | GET_QUERY_INFO |
Get query plan information |
| 26 | SAVEPOINT |
Set or rollback to savepoint |
| 27 | PARAMETER_INFO |
Get parameter metadata |
| 28–30 | XA_* |
XA distributed transaction operations |
| 31 | CON_CLOSE |
Close the CAS connection |
| 32 | CHECK_CAS |
Ping the CAS process |
| 33 | MAKE_OUT_RS |
Create output result set |
| 34 | GET_GENERATED_KEYS |
Get auto-generated keys |
| 35 | LOB_NEW |
Create a new LOB handle |
| 36 | LOB_WRITE |
Write data to a LOB |
| 37 | LOB_READ |
Read data from a LOB |
| 38 | END_SESSION |
End the CAS session |
| 39 | GET_ROW_COUNT |
Get affected row count |
| 40 | GET_LAST_INSERT_ID |
Get last auto-increment ID |
| 41 | PREPARE_AND_EXECUTE |
Combined prepare + execute |
Packet Classes¶
pycubrid implements 20 packet classes in pycubrid.protocol. Each class provides:
write()— Serialize the request (some takecas_infoparameter)parse(data)— Deserialize the response
ClientInfoExchangePacket¶
Handshake packet — no standard framing.
packet = ClientInfoExchangePacket()
raw = packet.write() # 10 bytes, no cas_info needed
packet.parse(response_4bytes) # Parses new_connection_port
| Attribute | Type | Description |
|---|---|---|
new_connection_port |
int |
Port for the CAS process (0 = reuse current) |
OpenDatabasePacket¶
Open a database session — sends 628 raw bytes, receives framed response.
packet = OpenDatabasePacket(database="testdb", user="dba", password="")
raw = packet.write() # 628 bytes, no cas_info
packet.parse(response_data) # Framed response with CAS info prefix
| Attribute | Type | Description |
|---|---|---|
cas_info |
bytes |
CAS session info (4 bytes) |
response_code |
int |
0 on success, negative on error |
broker_info |
dict |
{db_type, protocol_version, statement_pooling} |
session_id |
int |
Server session identifier |
Database, user and password are encoded with the connection charset (default
UTF-8) and cut to their 32-byte fields on a character boundary, so a multibyte
character is never split (#86).
Connection charset¶
The CAS protocol carries no client charset and the broker converts nothing: the
server interprets request text in the database charset and returns each value
in its column's charset. charset (default "utf-8", #86) is therefore a
client-side codec. Every packet built by a connection carries it
(packet.encoding, set before write()), and PacketWriter / PacketReader
use it for SQL text, FC9 arguments, credentials, character values, collection
elements, column metadata names and defaults, and error text (with
errors="replace", as are LOB file locators, which embed the table name).
Fetched JSON values are always UTF-8. NUMERIC text, timezone names and the
engine version string stay UTF-8, and LOB contents are raw bytes. With
euc_kr, Hangul outside KS X 1001, which Python encodes as an 8-byte makeup
sequence starting A4 D4, is treated as unencodable. An encode failure raises DataError inside write(), before the
request is sent; a strict decode failure raises DataError after the whole
reply was read, so the session stays usable.
PrepareAndExecutePacket¶
Combined prepare + execute (FC=41) — the primary packet for SQL operations.
| Attribute | Type | Description |
|---|---|---|
query_handle |
int |
Server-assigned query handle |
statement_type |
int |
CUBRIDStatementType code |
bind_count |
int |
Number of bind parameters |
column_count |
int |
Number of result columns |
columns |
list[ColumnMetaData] |
Column metadata |
total_tuple_count |
int |
Total rows in result set |
result_count |
int |
Number of result info entries |
result_infos |
list[ResultInfo] |
Per-statement result info |
tuple_count |
int |
Rows in the initial fetch |
rows |
list[tuple[Any, ...]] |
Fetched row data |
PreparePacket¶
Prepare a statement (FC=2) — separate prepare step.
The internal packet accepts prepare_flag=NORMAL (legacy default) or
HOLDABLE=0x08, plus effective autocommit. SQL with an embedded NUL or text the
connection charset cannot encode is rejected before FC2. This is internal wire groundwork for
#439, not a public prepared cursor.
executemany(..., prepared=True) (#839) sets carries_deferred_closes: its FC2 then appends
queued deferred-close handle ids after the autocommit byte, which CAS fn_prepare_internal
frees before preparing, as for PREPARE_AND_EXECUTE (#488).
| Attribute | Type | Description |
|---|---|---|
query_handle |
int |
Server-assigned query handle |
statement_type |
int |
Statement type code |
bind_count |
int |
Number of bind parameters |
column_count |
int |
Number of result columns |
columns |
list[ColumnMetaData] |
Column metadata |
ExecutePacket¶
Execute a prepared statement (FC=3).
After ten fixed arguments, the packet writes two length-prefixed arguments
per validated scalar binding: type byte and value bytes. The supported
internal subset is signed INT32 (8, four big-endian bytes), CHAR in the
connection charset (1, bytes plus NUL), and SQL NULL (0, zero bytes). Empty CHAR is a single
NUL byte, not NULL. Since #840 it also covers BIGINT (21, eight big-endian
bytes), DOUBLE (12, eight bytes, finite only), NUMERIC (7, ASCII decimal
text plus NUL), DATE (13), TIME (14) and DATETIME (22) as seven
big-endian int16 fields (14 bytes; the broker reads TIME at offset 6),
DATETIMETZ (31, those 14 bytes plus the zone text without NUL) and VARBIT
(6, at least one byte; a zero-length value reads as NULL). See
Prepared host-variable mapping. The optional bind_count must match the number of
bindings. The forward-only byte follows effective autocommit: 1 in auto
mode, 0 in manual mode. No FC41 fallback or SQL literal rendering occurs.
The typed collection binding (#482) uses
the same pair. The type argument is the collection kind: SET (16), MULTISET
(17) or SEQUENCE (18). The value argument is one element-type byte, INT
(8) or STRING (2, the byte the official driver sends), followed by one
int32 length + payload per element. There is no element count in the
request. An INT element is four big-endian bytes, a string element is the
connection-charset bytes plus NUL, and a NULL element has length 0. An empty
collection is the element-type byte alone. Whole SQL NULL is the scalar NULL
pair, not a collection. The broker stops parsing silently and keeps the
partial collection when an element length overruns the argument
(cas_execute.c), so elements are validated before any bytes are built:
input must be a flat tuple; INT elements are int (not bool) or canonical
decimal strings, not both in one collection; string elements are str
without NUL. Mixed, nested, bool, float and bytes elements are rejected.
On CUBRID 10.2 and 11.4 the broker rejects the MULTISET kind with error -454
(it wraps the multiset with db_make_set()), and a SET value stored into a
MULTISET column loses duplicates; a SEQUENCE value keeps them.
The public pycubrid.compat.native set.imports() (#440) builds this pair
the way the official driver does: every element is a STRING (2) element,
whatever element type is requested (INT elements are their decimal text), and
the default kind is SET (16), so its bytes equal the official request.
kind=MULTISET is sent as SEQUENCE (18), never as 17.
A LOB-handle binding (#441; public only through pycubrid.compat.native
fetch_lob()/bind_lob()) uses the same pair.
The type argument is BLOB (23) or CLOB (24) and the value argument is the
packed handle [int32 db_type][int64 size][int32 locator length][locator
NUL]. This is byte for byte what the official driver sends for bind_lob()
when its lob type matches the column; the official driver takes the type byte
from result column 1, and the broker ignores that byte in favor of the
handle's own db_type. The
db_type must match the type argument (BLOB 33, CLOB 34), and the
locator length must cover the rest of the handle exactly. The broker builds
the stored value from the handle's own size field (caslob_to_dblob in
cas_execute.c), so a handle with a stale size stores the wrong length. CCI
raises that field to the end of each LOB_WRITE, and Lob.write() now does
the same. A binding is sent only on the physical session it was made for,
identified by its connection and that connection's session generation
(generation numbers alone repeat across connections). On
CUBRID 10.2 and 11.4 a fetched handle is copied into the new row and can be
bound again, while a handle from LOB_NEW names a temporary file that its first
autocommit statement takes over, even when that statement fails.
For protocol version >1, an include_column_info=1 response carries the
full FC2 prepare-info tail before the shard ID and inline FETCH. The parser
updates statement/bind/column metadata and rejects truncated tails. A
negative per-result record is surfaced through the existing code-to-DB-API
exception mapping rather than treated as success; its broker text is not
copied into the exception. These packet primitives remain internal until #439 supplies
physical-session ownership, cursor lifecycle and full live support gates.
| Attribute | Type | Description |
|---|---|---|
total_tuple_count |
int |
Total result rows |
result_count |
int |
Number of result infos |
result_infos |
list[ResultInfo] |
Per-statement results |
tuple_count |
int |
Inline fetch row count |
rows |
list[tuple[Any, ...]] |
Inline fetched rows |
FetchPacket¶
Fetch result rows (FC=8) — used for paginated row retrieval.
| Attribute | Type | Description |
|---|---|---|
tuple_count |
int |
Number of fetched rows |
rows |
list[tuple[Any, ...]] |
Row data |
CommitPacket¶
Commit transaction (FC=1 with CCITransactionType.COMMIT).
No result attributes — raises an exception on error.
RollbackPacket¶
Rollback transaction (FC=1 with CCITransactionType.ROLLBACK).
No result attributes — raises an exception on error.
CloseDatabasePacket¶
Close the CAS session (FC=31).
CloseQueryPacket¶
Release a query handle (FC=6).
GetEngineVersionPacket¶
Get server version (FC=15).
| Attribute | Type | Description |
|---|---|---|
engine_version |
str |
Version string (e.g., "11.2.0.0378") |
GetSchemaPacket¶
Schema introspection (FC=9).
| Attribute | Type | Description |
|---|---|---|
query_handle |
int |
Handle for fetching schema rows |
tuple_count |
int |
Number of schema entries |
columns |
list |
Condensed four-field column metadata |
Owned schema requests (#455, #456): the verified FC9
request carries length-prefixed arguments in this order: schema type (int),
first name/pattern (string or NULL), second name/pattern (string or NULL),
flags (byte), then shard ID (int, protocol V5 and newer). NULL is a zero-length
argument; an empty string contains its NUL terminator and is a different argument.
Strings use the connection charset (default UTF-8).
After the response handle and tuple count, the column count precedes condensed
columns: type (one or two bytes), scale (int16), precision (int32), name length
(int32) and name. Unlike ordinary SELECT metadata, this layout has no separate
attribute/table names, nullability, default or constraint flags. The private
decoder therefore exposes only type, scale, precision and name; it preserves the
existing collection-kind normalization rather than inventing absent fields.
GetSchemaPacket.write/parse now uses this layout. Connection getters register
the original packet identity and immutable handle/count/columns. Consume via
fetch_schema_info(packet) (FC8 until all rows arrive, followed by FC6 even for
zero rows) or abandon via close_schema_info(packet). Both remain on the original
CAS session, without reconnect/replay. The unchanged handle-only FC6 omits the
optional auto-commit argument, whose server default is false. Commit/rollback
close active schema handles before END_TRAN; physical teardown retires ownership.
Async ownership registration and fetch/close are atomic under the connection lock;
in-flight cancellation discards the transport rather than sending FC6 over an
unread reply. The #457 live matrix covers CLASS/VCLASS/ATTRIBUTE/CONSTRAINT/
PRIMARY_KEY/IMPORTED_KEYS/EXPORTED_KEYS on 10.2/11.4, not all schema codes or
native-driver parity.
Source references: CAS FC9 arguments,
CCI condensed columns,
and JDBC schema request.
BatchExecutePacket¶
Batch execute (FC=20) — multiple SQL statements in one request.
Reply (CAS ux_execute_batch): int result code, int executed statement count, then
per statement a statement-type byte and either int count >= 0 and an 8-byte OID,
or a negative int (DBMS_ERROR_INDICATOR, -2, for V3+, otherwise the error code
itself), the error code (V3+) and a length-prefixed message; a shard id follows for V5+. A failed statement does not
stop the later ones; as in ux_execute_array, the CAS stops early only after an
ER_INTERRUPTED (-4) statement, which it reports, rewriting the executed count.
An executed count that is negative or larger than the number of statements sent,
or a short reply that does not end with an interrupted statement, is malformed.
| Attribute | Type | Description |
|---|---|---|
results |
list[tuple[int, int]] |
(stmt_type, result_count) per successful statement |
errors |
list[dict] |
{code, message, index} for failed statements |
row_results |
tuple[tuple[int, int, str], ...] |
(count, 0, "") or (-1, code, message) per executed statement, in order (#841) |
ExecuteArrayPacket¶
Execute array (FC=21, #839) — one prepared statement executed once per row
of bound values. Sent by executemany(..., prepared=True) after a PREPARE
(FC=2) on the same session.
Request (CAS fn_execute_array): int query handle, int query timeout (0, protocol
V4+ only), byte autocommit, then one (type byte, value) argument pair per marker
per row, in row order: the same pair ExecutePacket writes for one binding.
Every row must have exactly the server's bind count; the CAS would silently
ignore extra values.
Reply (CAS ux_execute_array): int result code, int executed row count, then per
row either int count >= 0 and an 8-byte OID, or a negative int
(DBMS_ERROR_INDICATOR, -2, for V3+, otherwise the error code itself), the error
code (V3+) and a length-prefixed message; a shard id follows for V5+. The CAS stops
early only after an ER_INTERRUPTED (-4) row, which it reports; any other short
reply is malformed.
| Attribute | Type | Description |
|---|---|---|
row_results |
tuple[tuple[int, int, str], ...] |
(count, 0, "") or (-1, code, message) per executed row, in row order |
errors |
list[dict] |
{code, message, index} for failed rows |
With autocommit on, the CAS commits each successful row and aborts each failing one; with autocommit off, a deadlock or a server shutdown fails the whole request.
LOBNewPacket¶
Create LOB handle (FC=35).
| Attribute | Type | Description |
|---|---|---|
lob_handle |
bytes |
Server-generated LOB handle |
LOBWritePacket¶
Write LOB data (FC=36).
| Attribute | Type | Description |
|---|---|---|
bytes_written |
int |
Bytes the server wrote |
The request carries the packed handle, an int64 offset and the data. The
server only appends: on CUBRID 10.2 and 11.4 a write at any offset other than
the current size, inside the value or past its end, fails with -1016. After
a successful write, Lob.write() raises the handle's size field to
offset + bytes_written (never lowers it), as CCI does.
LOBReadPacket¶
Read LOB data (FC=37).
| Attribute | Type | Description |
|---|---|---|
bytes_read |
int |
Actual bytes read |
lob_data |
bytes |
The read data |
GetLastInsertIdPacket¶
Get last insert ID (FC=40).
| Attribute | Type | Description |
|---|---|---|
last_insert_id |
str |
Last auto-increment value as string |
GetDbParameterPacket / SetDbParameterPacket¶
Get/Set database parameters (FC=4 / FC=5).
| Attribute | Type | Description |
|---|---|---|
value |
int |
Parameter value (get result / set input) |
Available parameters (CCIDbParam):
| Code | Name | Description |
|---|---|---|
| 1 | ISOLATION_LEVEL |
Transaction isolation level |
| 2 | LOCK_TIMEOUT |
Lock wait timeout |
| 3 | MAX_STRING_LENGTH |
Maximum string length |
| 4 | AUTO_COMMIT |
Auto-commit mode (0/1) |
PacketWriter¶
pycubrid.packet.PacketWriter — serializes data in CAS wire format.
Public Methods¶
| Method | Description |
|---|---|
add_byte(value) |
Write length-prefixed byte (1B length + 1B value) |
add_short(value) |
Write length-prefixed short (4B length + 2B value) |
add_int(value) |
Write length-prefixed int (4B length + 4B value) |
add_long(value) |
Write length-prefixed long (4B length + 8B value) |
add_float(value) |
Write length-prefixed float (4B length + 4B value) |
add_double(value) |
Write length-prefixed double (4B length + 8B value) |
add_bytes(value) |
Write length-prefixed raw bytes |
add_null() |
Write null marker (zero-length) |
add_date(y, m, d) |
Write length-prefixed date |
add_time(h, m, s) |
Write length-prefixed time |
add_timestamp(y,m,d,h,m,s) |
Write length-prefixed timestamp |
add_datetime(y,m,d,h,m,s,ms) |
Write length-prefixed datetime |
add_cache_time() |
Write cache time (two zero ints) |
to_bytes() |
Return all written bytes |
Internal Methods¶
| Method | Description |
|---|---|
_write_byte(value) |
Raw byte, no length prefix |
_write_short(value) |
Raw short (2B) |
_write_int(value) |
Raw int (4B) |
_write_long(value) |
Raw long (8B) |
_write_float(value) |
Raw float (4B) |
_write_double(value) |
Raw double (8B) |
_write_bytes(value) |
Raw bytes, no prefix |
_write_filler(count, value) |
Fill N bytes with value |
_write_null_terminated_string(value) |
Length-prefixed string in the connection charset (default UTF-8) + null terminator |
_write_fixed_length_string(value, length) |
Fixed-width null-padded string, cut on a character boundary |
PacketReader¶
pycubrid.packet.PacketReader — deserializes CAS wire data.
Primitive Parsers¶
| Method | Returns | Bytes Consumed |
|---|---|---|
_parse_byte() |
int |
1 |
_parse_short() |
int |
2 |
_parse_int() |
int |
4 |
_parse_long() |
int |
8 |
_parse_float() |
float |
4 |
_parse_double() |
float |
8 |
_parse_bytes(count) |
bytes |
count |
_parse_null_terminated_string(length) |
str |
length |
Every read stays inside the reply (#383). A length-prefixed read (bytes, text,
NUMERIC, JSON, a raw collection or LOB handle, _skip_bytes()) checks
0 <= length <= bytes_remaining() before it moves and raises ValueError
otherwise; a fixed-width read past the end raises struct.error or
IndexError. A failed read leaves the offset unchanged. Text readers return
"" for a non-positive length without moving. A decoded collection's elements
must fill its declared size exactly, and a LOB_READ byte count must fit the
reply (a count below the requested length is a valid short read). A fetched
BLOB/CLOB cell must be exactly the packed handle the CAS writes
(cas_execute.c add_res_data_lob_handle, 10.2 to 11.4; #933): a 16-byte header
[int32 db_type][int64 size][int32 locator size], then a locator that fills
the rest of the cell. The embedded db_type must be the server DB_TYPE of the
cell's type, BLOB 33 or CLOB 34 (not the CCI type 23/24). A shorter
header, a negative locator size, a locator longer or shorter than the rest of
the cell, or another embedded type raises ValueError, also when the row parser re-walks a
reply before raising DataError for an earlier cell. Bytes after the
declared cell belong to the next field. A size of -1 with an empty locator
(a value without an external file) stays valid, and the decoder does not check
the locator's trailing NUL. Each row cell
of a FETCH or inline execute reply must use exactly the bytes its size word
declares: fixed-width values (INT, DATE, OBJECT, ...) do not read the size
themselves, so the row parser checks it against the type's width before reading
the value (#523), also when it re-walks a reply before raising DataError. The
CAS writes SQL NULL as size -1; a non-positive size is NULL, except that a
BIT/VARBIT cell of size 0 is an empty bit string, b"" (#994). A negative FETCH tuple count is malformed too, and
so is a negative column count or column name, real-name, table-name or default
length in FC2, FC3 or FC41 column metadata (#555); a zero length is an empty
string. FC41 also rejects a negative bind count, total_tuple_count or inline
tuple count, and a column count the rest of the reply cannot hold (31 bytes per
column at least), as FC2 does; FC3 rejects a negative inline tuple count (#581).
Column metadata text that the connection codec cannot decode is deferred after
the remaining metadata is walked by its declared lengths (#581). FC41 and FC3
with refreshed columns then validate their remaining tail, counts and inline
rows before re-raising the first metadata DataError; later structural damage
still wins (#591). This error path does not call application JSON deserializers,
and validates subsequent cells even if an earlier row value is unrepresentable.
Known collection framing is checked even when normal results use opaque bytes;
negative element counts are malformed. A partially present inline-fetch header
is malformed; an absent optional header remains supported. A per-row error
message length in an FC20 (EXECUTE_BATCH) or FC21 (EXECUTE_ARRAY) reply
must be 0 <= length <= bytes_remaining(), and the type header of the FC40
(GET_LAST_INSERT_ID) value counts in its size like a row cell's, so a header
longer than the value is malformed (#897). The
connection turns these exceptions into OperationalError("malformed response
from broker") and closes; DataError stays reserved for a complete reply
whose value Python cannot represent (#492, #512). Unread bytes after the last
value a reply declares are not checked.
For known decoded collection member types, each length word and payload must
fit the collection and the value decoder must consume exactly its declared
bytes. After a complete element raises DataError, later elements still run
their real decoders: a malformed representation wins over the saved error
(#595). A complete collection retains the first conversion error and its cause.
NULL-only, opaque/disabled decoding and unsupported nested member layouts
retain their existing contracts; no recursive decoding capability is added.
Internal bounds re-walks use PacketReader.mark() and seek(position).
Seeking outside the reply or to a non-integer position raises ValueError
without moving the reader; zero and the end of the reply are valid positions.
Composite Parsers¶
| Method | Returns | Description |
|---|---|---|
_parse_date() |
datetime.date |
3 shorts (year, month, day) |
_parse_time() |
datetime.time |
3 shorts (hour, minute, second) |
_parse_datetime() |
datetime.datetime |
7 shorts (y,m,d,h,m,s,ms) |
_parse_timestamp() |
datetime.datetime |
6 shorts (y,m,d,h,m,s) |
_parse_timestamptz() |
datetime.datetime |
6 shorts (y,m,d,h,m,s) + timezone string; second precision (TIMESTAMPTZ/TIMESTAMPLTZ) |
_parse_datetimetz() |
datetime.datetime |
7 shorts (y,m,d,h,m,s,ms) + timezone string; millisecond precision (DATETIMETZ/DATETIMELTZ) |
_parse_numeric(size) |
Decimal |
Null-terminated string → Decimal |
_parse_object() |
str |
OID string ("OID:@page|slot|volume") |
read_blob(size) |
dict |
BLOB handle info |
read_clob(size) |
dict |
CLOB handle info |
read_error(length) |
(int, str) |
Error code and message |
bytes_remaining() |
int |
Unread bytes in buffer |
Data Types on the Wire¶
Column metadata preserves the first type byte's 0x60 collection-kind bits:
0x20 is SET, 0x40 MULTISET and 0x60 SEQUENCE. With 0x80 set, a second
byte carries the full scalar/element type (including codes above 31); otherwise
the low five bits carry it. Collection row dispatch uses the collection kind,
not that element type.
Cells of a CALL / EVALUATE result and of a column whose metadata type is
NULL (SELECT NULL, ...) carry their own type header before the value, and
the cell size counts it. Protocol 7+ brokers (CUBRID 10.2+) write it like
column metadata: 0x80 | collection bits | charset, then the type byte, for
example 00000006 83 08 0000002a for CALL of a function returning INT 42
and 0000000a 83 13 <8-byte OID> for CALL find_user('dba') ON CLASS db_user.
Older brokers write one type byte. The driver reads either layout (#542); a
header longer than its cell is a malformed reply.
A collection value is one element-type byte, a 4-byte element count, then a
4-byte length and payload per element; a NULL element has length -1 and no
payload. An empty BIT/VARBIT element (X'') has length 0 and decodes as
b""; a length-0 element of any other type decodes as None (#998). When every element is NULL, CUBRID 10.2/11.4 send element type 0
(NULL): {} is 00 00000000 and {NULL, NULL} is 00 00000002 ffffffff ffffffff.
Column data is transmitted as a 4-byte size prefix followed by the raw data. The type determines how the data bytes are interpreted:
| Type Code | Name | Wire Format |
|---|---|---|
| 0 | NULL |
size ≤ 0 → None |
| 1 | CHAR |
Null-terminated string in the connection charset (default UTF-8) |
| 2 | STRING |
Null-terminated string in the connection charset (default UTF-8) |
| 3 | NCHAR |
Null-terminated string in the connection charset (default UTF-8) |
| 4 | VARNCHAR |
Null-terminated string in the connection charset (default UTF-8) |
| 5 | BIT |
Raw bytes; size 0 → b"" (#994) |
| 6 | VARBIT |
Raw bytes; size 0 → b"" (#994) |
| 7 | NUMERIC |
Null-terminated string → Decimal |
| 8 | INT |
4-byte big-endian signed int |
| 9 | SHORT |
2-byte big-endian signed short |
| 10 | MONETARY |
8-byte big-endian double |
| 11 | FLOAT |
4-byte big-endian float |
| 12 | DOUBLE |
8-byte big-endian double |
| 13 | DATE |
3 shorts: year, month, day |
| 14 | TIME |
3 shorts: hour, minute, second |
| 15 | TIMESTAMP |
6 shorts: y, m, d, h, m, s |
| 16–18 | SET/MULTISET/SEQUENCE |
Raw bytes |
| 19 | OBJECT |
4B page + 2B slot + 2B volume |
| 21 | BIGINT |
8-byte big-endian signed long |
| 22 | DATETIME |
7 shorts: y, m, d, h, m, s, ms |
| 23 | BLOB |
Packed LOB handle → dict |
| 24 | CLOB |
Packed LOB handle → dict |
| 25 | ENUM |
Null-terminated string in the connection charset (default UTF-8) |
Column Metadata¶
Each column in a result set carries metadata parsed from the wire:
@dataclass
class ColumnMetaData:
column_type: int # CUBRIDDataType code
scale: int # Decimal scale (-1 if not applicable)
precision: int # Precision (-1 if not applicable)
name: str # Column alias
real_name: str # Actual column name
table_name: str # Source table name
is_nullable: bool # Allows NULL (normalized from the inverse wire flag)
default_value: str # Default value expression
is_auto_increment: bool # AUTO_INCREMENT column
is_unique_key: bool # Part of unique index
is_primary_key: bool # Part of primary key
is_reverse_index: bool # Has reverse index
is_reverse_unique: bool # Has reverse unique index
is_foreign_key: bool # Part of foreign key
is_shared: bool # Shared attribute
The raw column-metadata byte is is_non_null: zero permits NULL and nonzero
means NOT NULL (the broker normalizes it to 0/1). PacketReader supplies that
byte to the metadata parser, which derives is_nullable and the public
cursor.description null_ok field using raw == 0.
Error Handling¶
When response_code < 0, the response contains an error:
graph LR
code["Error Code (4B int)"] --> message["Error Message (null-terminated string)"]
The negative outer response indicator identifies the error domain (CAS -1
or DBMS -2); the following inner error code is a separate number.
_raise_error() first looks up that inner numeric code in the existing error
mapping. Only an unmapped inner code exactly -1 uses the text fallback below;
an outer CAS indicator of -1 does not select that fallback. Other unmapped
codes raise DatabaseError, regardless of their message text. Exceptions retain
the received inner number unchanged in code and errno; the outer domain is
exposed as DatabaseError.error_source: "cas" for -1, "server" for -2,
and None for any other value (#903). The driver does not set
BROKER_RENEWED_ERROR_CODE in the handshake, so a CAS error arrives in the old
numbering (CAS_ER_* plus 9000, -1000 to -1200), which overlaps server codes;
the indicator is what tells them apart.
Text fallback for inner code -1 only:
| Error pattern | Exception |
|---|---|
unique, duplicate, foreign key, constraint violation |
IntegrityError |
syntax, unknown class, does not exist, not found |
ProgrammingError |
| Other fallback text | DatabaseError |
Renewed CAS numbers: evaluation decision (#505)¶
Keep the current handshake function byte (zero-based index 7) at 0, for both
plaintext and STARTTLS requests, and preserve raw public code/errno.
Renewed-code bit 0x80 is supported by the researched brokers, but enabling it
or adding an opt-in requires a separate domain-aware consumer/migration contract.
This deferral does not resolve existing numeric ambiguity. Legacy CAS
communication -1003 overlaps engine
ER_SM_CONSTRAINT_HAS_DIFFERENT_TYPE=-1003; do not infer a domain or normalize
numbers by adding/subtracting 9000 alone. The pinned
broker serializer
and CCI conversion
use domain/protocol/capability conditions, not just numeric ranges; the
engine collision
is source-proven, not reproduced live. The separate holdable bit 0x40 in the
function byte is outside this evaluation and remains disabled.
On 2026-10-03, frozen driver 984d3a2c9e30d613936d69195719a3439e1c08e1
with Python 3.12.13 compared flag 0 versus 0x80 on owned builds
10.2.18.9024, 11.0.16.0419, 11.2.9.0866 and 11.4.6.1963. A validly framed FC4
request with unsupported parameter ID 255 returned outer CAS domain -1 and
inner -1011 versus -10011; each surfaced unchanged as DatabaseError.code
and errno. The DBMS syntax control retained outer -2, inner -493 and
ProgrammingError under both flags. Same-session health queries succeeded
without replay or reconnect. This proves only that producer on those builds,
not every patch or auth/embedded/batch/TLS/redirect/reconnect path, the live
numeric collision or downstream migration. No negotiation/API behavior changed.
Constants Reference¶
CAS Protocol Constants¶
class CASProtocol:
MAGIC_STRING = "CUBRK" # Handshake magic (plaintext)
MAGIC_STRING_SSL = "CUBRS" # Handshake magic (STARTTLS-requested)
CLIENT_JDBC = 3 # Client type identifier
PROTO_INDICATOR = 0x40 # Protocol indicator bit
VERSION = 8 # Protocol version
CAS_VERSION = 0x48 # Combined version byte
Wire Data Sizes¶
class DataSize:
BYTE = 1 BOOL = 1
SHORT = 2 INT = 4
FLOAT = 4 LONG = 8
DOUBLE = 8 OBJECT = 8
OID = 8 BROKER_INFO = 8
DATE = 14 TIME = 14
DATETIME = 14 TIMESTAMP = 14
RESULTSET = 4 DATA_LENGTH = 4
CAS_INFO = 4
Statement Types¶
| Code | Name | Code | Name |
|---|---|---|---|
| 0 | ALTER_CLASS |
20 | INSERT |
| 4 | CREATE_CLASS |
21 | SELECT |
| 5 | CREATE_INDEX |
22 | UPDATE |
| 9 | DROP_CLASS |
23 | DELETE |
| 10 | DROP_INDEX |
24 | CALL |
| 16 | ROLLBACK_WORK |
126 | CALL_SP |
| 17 | GRANT |
127 | UNKNOWN |
Transaction Types¶
| Code | Name |
|---|---|
| 1 | COMMIT |
| 2 | ROLLBACK |
Isolation Levels¶
| Code | Name |
|---|---|
| 0x01 | COMMIT_CLASS_UNCOMMIT_INSTANCE (legacy pre-MVCC) |
| 0x02 | COMMIT_CLASS_COMMIT_INSTANCE |
| 0x03 | REP_CLASS_UNCOMMIT_INSTANCE (legacy pre-MVCC) |
| 0x04 | REP_CLASS_COMMIT_INSTANCE (READ COMMITTED, server default) |
| 0x05 | REP_CLASS_REP_INSTANCE |
| 0x06 | SERIALIZABLE |
On CUBRID's MVCC engine (10.0+) only levels
0x04/0x05/0x06are accepted; the lower codes0x01-0x03are legacy pre-MVCC levels. CUBRID's server default isolation level is READ COMMITTED (0x04).