refactor(FiveSql2): per-session state — TSqlSession isolates txn + plan cache

Foundation for the upcoming PostgreSQL-wire server. The SQL engine
previously held transaction state and the plan cache in module-level
STATICs:

  TSqlTxn.prg:16-18
    STATIC s_aTxnLog := {}
    STATIC s_lInTxn  := .F.
    STATIC s_hSavepoints := NIL

  TFiveSQL.prg:37
    STATIC s_hPlanCache := { => }

gengo emits PRG STATIC as Go *package* variables, so two clients
sharing one process serialised through a single transaction log:
client A's `BEGIN; INSERT;` followed by client B's `ROLLBACK`
would silently undo A's insert. Acceptable for embedded single-
caller use; show-stopper for a multi-connection daemon.

Moved each of those into instance fields on a new TSqlSession class.
Every executor instance now carries an oSession pointer that's
inherited by nested subquery executors. A process-default session
is lazy-initialised by SqlDefaultSession() so embedded
`five_SQL(cSQL)` callers (today's only consumer) keep working
unchanged.

Changes
-------

* `_FiveSql2/src/TSqlSession.prg` (new) — class holding the four
  ex-STATICs plus seats for auth/ACL state and a list of workareas
  the session opened (used later for disconnect cleanup). Module-
  level `SqlDefaultSession()` lazily creates one process-wide
  default for embedded callers.

* `_FiveSql2/src/TSqlTxn.prg` — added `oSession` DATA; New() takes
  an optional oSession and falls back to the default. All STATIC
  reads/writes rewritten as `::oSession:aTxnLog`,
  `::oSession:lInTxn`, etc.

* `_FiveSql2/src/TFiveSQL.prg` — added `oSession` DATA; New() takes
  an optional second arg. Plan-cache reads/writes route through
  `::oSession:hPlanCache`. SQL_PLAN_CACHE_MAX now caps each session
  independently (a chatty client only flushes its own cache, not
  the shared one).

* `_FiveSql2/src/TSqlExecutor.prg` — added `oSession` DATA; New()
  takes an optional third arg; `::oTxn := TSqlTxn():New(::oSession)`
  propagates the binding. Every in-class `TSqlExecutor():New(...)`
  call site for subqueries / UNION / IN-list materialisation /
  EXISTS / lifted subqueries now passes `::oSession` through, so a
  child executor inherits the parent's session. Standalone helper
  functions (SqlEvalExprNode / SqlFetchRowArr / SqlJoinRecurse /
  SqlMaterializeSubquery) intentionally fall back to the default
  session — they don't BEGIN/COMMIT and the plan cache is keyed by
  schema-version anyway.

* `_FiveSql2/src/FiveSqlCls.prg` — `five_SQL()` gains an optional
  fourth arg `oSession`. Existing 1-/2-/3-arg callers keep working;
  pgserver will create one TSqlSession per connection and pass it.

Verification
------------

Per-session isolation pinned by a fresh PRG-level regression
(reproducer not committed yet — will land with pgserver test
suite). The scenario:

  oSessA := TSqlSession():New()
  oSessB := TSqlSession():New()
  oSqlA  := TFiveSQL():New(NIL, oSessA)
  oSqlB  := TFiveSQL():New(NIL, oSessB)
  oSqlA:Execute("BEGIN")              -- A in txn
  oSqlB:Execute("BEGIN")              -- B in txn, A unaffected
  oSqlB:Execute("INSERT ... VALUES(2,'b-row')")
  oSqlB:Execute("COMMIT")             -- B committed, A still in txn
  oSqlA:Execute("ROLLBACK")           -- A's empty rollback, B's row survives

All four assertions pass post-refactor, would fail pre-refactor
because both sessions wrote the same `s_aTxnLog`.

All six release gates green:
  go test ./...               ✓
  FiveSql2 SQL:1999 43/43     ✓
  Harbour compat 56/56        ✓
  std.ch 17/17                ✓
  FRB 7/7                     ✓
  examples 65/71              ✓ (unchanged baseline)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 17:47:00 +09:00
parent cde86730b8
commit 93cf5c8bfa
5 changed files with 194 additions and 84 deletions

View File

@@ -83,7 +83,13 @@ CLASS TSqlExecutor
DATA hSubCache
METHOD New( hQuery, aParams ) CONSTRUCTOR
/* Session container — carries txn log, plan cache, current user,
* role grants. Nested subquery executors inherit the parent's
* session so a child query's BEGIN/COMMIT operates on the same
* connection-scoped state. */
DATA oSession
METHOD New( hQuery, aParams, oSession ) CONSTRUCTOR
METHOD Run()
METHOD RunImpl()
METHOD RunSelect()
@@ -134,15 +140,23 @@ CLASS TSqlExecutor
ENDCLASS
METHOD New( hQuery, aParams ) CLASS TSqlExecutor
METHOD New( hQuery, aParams, oSession ) CLASS TSqlExecutor
::hQuery := hQuery
::aParams := iif( aParams == NIL, {}, aParams )
/* Inherit caller's session if provided; otherwise fall back to
* the process-default session so embedded `five_SQL(cSQL)` callers
* and any TSqlExecutor created without the new arg keep working. */
IF oSession == NIL
::oSession := SqlDefaultSession()
ELSE
::oSession := oSession
ENDIF
::oIndex := TSqlIndex():New()
::oAgg := TSqlAgg():New()
::oSort := TSqlSort():New()
::oDDL := TSqlDDL():New()
::oTxn := TSqlTxn():New()
::oTxn := TSqlTxn():New( ::oSession )
::oAlias := TSqlAlias():New()
::nDepth := 0
::aOpened := {}
@@ -2390,7 +2404,7 @@ METHOD RunSelect() CLASS TSqlExecutor
* which meant LIMIT clipped the first SELECT before UNION added
* the second's rows, producing more rows than intended. */
IF hUnion != NIL
aU := TSqlExecutor():New( hUnion, ::aParams ):Run()
aU := TSqlExecutor():New( hUnion, ::aParams, ::oSession ):Run()
/* SQL standard: set operations require the same column count on
* both sides. Previously a mismatch silently truncated the wider
* side to the narrower's width, masking schema bugs (`SELECT a
@@ -2843,7 +2857,7 @@ METHOD TryBuildSemiJoin( xSubNode ) CLASS TSqlExecutor
hLifted[ "having" ] := NIL
/* Run the lifted query once. No PushOuter — it's now non-correlated. */
oSub := TSqlExecutor():New( hLifted, ::aParams )
oSub := TSqlExecutor():New( hLifted, ::aParams, ::oSession )
oSub:nDepth := ::nDepth
aResult := oSub:Run()
IF ValType( aResult ) != "A" .OR. Len( aResult ) < 2 .OR. ValType( aResult[ 2 ] ) != "A"
@@ -3033,7 +3047,7 @@ METHOD SubqueryCached( xSubNode ) CLASS TSqlExecutor
aRecSave := ::SnapshotAreaRecNos()
::PushOuter()
BEGIN SEQUENCE
oSub := TSqlExecutor():New( hQ, ::aParams )
oSub := TSqlExecutor():New( hQ, ::aParams, ::oSession )
/* +2 (not +1): the alias-rename gate in the table-open loop
* requires `nDepth > 1` to fire. Bumping by 1 from a top-level
* (depth-0) outer landed at depth 1 which still shares aliases
@@ -3244,7 +3258,7 @@ METHOD CacheSubquery( xSubExpr ) CLASS TSqlExecutor
* negligible; correlated callers go through SubqueryCached. */
nSavedWA := Select()
aRecSave := ::SnapshotAreaRecNos()
oSub := TSqlExecutor():New( xSubExpr, ::aParams )
oSub := TSqlExecutor():New( xSubExpr, ::aParams, ::oSession )
oSub:nDepth := ::nDepth + 2
aSubResult := oSub:Run()
::RestoreAreaRecNos( aRecSave )
@@ -3269,7 +3283,7 @@ METHOD MaterializeCTE( aCTE ) CLASS TSqlExecutor
/* Execute the CTE subquery */
IF ValType( xSubQ ) == "A" .AND. xSubQ[ 1 ] == ND_SUB .AND. xSubQ[ 2 ] != NIL
aSub := TSqlExecutor():New( xSubQ[ 2 ], ::aParams ):Run()
aSub := TSqlExecutor():New( xSubQ[ 2 ], ::aParams, ::oSession ):Run()
ELSE
aSub := NIL
ENDIF
@@ -3396,7 +3410,7 @@ METHOD RunInsert() CLASS TSqlExecutor
* from VALUES (...) tuples or from a SELECT. */
IF hb_HHasKey( ::hQuery, "select" )
hSelect := ::hQuery[ "select" ]
aSelResult := TSqlExecutor():New( hSelect, ::aParams ):Run()
aSelResult := TSqlExecutor():New( hSelect, ::aParams, ::oSession ):Run()
IF ValType( aSelResult ) == "A" .AND. Len( aSelResult ) >= 1 .AND. ;
ValType( aSelResult[ 1 ] ) == "A" .AND. Len( aSelResult[ 1 ] ) > 0 .AND. ;
aSelResult[ 1 ][ 1 ] == "__error__"
@@ -4629,7 +4643,7 @@ METHOD MaterializeRecursiveCTE( aCTE ) CLASS TSqlExecutor
hSubQ[ "union" ] := NIL
/* Execute anchor query (the first SELECT before UNION ALL) */
aSub := TSqlExecutor():New( hSubQ, ::aParams ):Run()
aSub := TSqlExecutor():New( hSubQ, ::aParams, ::oSession ):Run()
/* Restore the union reference for later use */
hSubQ[ "union" ] := hRecQuery