Skip to content

Expression index on a computed column is not usable in other attachments and gets corrupted ("missing entries") when the table has a computed column calling a procedure that reads the same table #9165

Open

Description

@tomaszdubiel18

This report was created with the support of AI. I hope it will help with the analysis of the root cause of the problem.
Related to #7945 — same root symptom, but it is not only a plan/optimizer problem: the index gets physically corrupted.

Environment

Description

Table T has:

  • a computed column C_PROC that calls a stored procedure, and that procedure reads table T itself,
  • a computed column C_KEY that depends on another computed column C_BIT,
  • an expression index on C_KEY.

Observed behaviour:

  1. Only the attachment that created (or rebuilt) the index can use it. In any new attachment the optimizer does not match the index (PLAN (T NATURAL)), and forcing it fails with index T_C_KEY cannot be used in the specified plan (this is Problem with using a computed index on a computed column #7945).
  2. When a new attachment updates a row so that the value of C_KEY changes, and the old record version is then garbage collected, the index loses all entries for that row. gfix -v -full reports:
Error: Index 2 is corrupt {missing entries for record 0} in table T (128)

ALTER INDEX ... INACTIVE / ACTIVE fixes it, but the corruption returns as soon as rows are modified again from normal attachments.

Steps to reproduce

Adjust the database path (2 places) and run:

isql -user SYSDBA -password masterkey -i repro.sql
gfix -v -full -user SYSDBA -password masterkey localhost:/tmp/repro.fdb

repro.sql:

CREATE DATABASE 'localhost:/tmp/repro.fdb';

SET TERM ^ ;
CREATE PROCEDURE P_READ_T (ID INTEGER) RETURNS (RES INTEGER) AS BEGIN SUSPEND; END^
SET TERM ; ^

CREATE TABLE T (
    ID      INTEGER NOT NULL PRIMARY KEY,
    FLAGS   INTEGER DEFAULT 0 NOT NULL,
    C_PROC  COMPUTED BY ((SELECT RES FROM P_READ_T(T.ID))),
    C_BIT   COMPUTED BY (CAST(SIGN(BIN_AND(FLAGS, 64)) AS SMALLINT)),
    C_KEY   COMPUTED BY (CASE WHEN (C_BIT = 1) THEN ID ELSE -ID END)
);

SET TERM ^ ;
ALTER PROCEDURE P_READ_T (ID INTEGER) RETURNS (RES INTEGER) AS
BEGIN
  SELECT T.FLAGS FROM T WHERE T.ID = :ID INTO :RES;
  SUSPEND;
END^

EXECUTE BLOCK AS
  DECLARE I INTEGER = 1;
BEGIN
  WHILE (I <= 1000) DO
  BEGIN
    INSERT INTO T (ID, FLAGS) VALUES (:I, 0);
    I = I + 1;
  END
END^
SET TERM ; ^
COMMIT;

CREATE INDEX T_C_KEY ON T COMPUTED BY (C_KEY);
COMMIT;

SET PLAN ON;
-- Attachment that created the index: index is used
SELECT COUNT(*) FROM T WHERE C_KEY > 0;

-- New attachment
CONNECT 'localhost:/tmp/repro.fdb';
SET PLAN ON;
-- Expected: PLAN (T INDEX (T_C_KEY)); actual: PLAN (T NATURAL)
SELECT COUNT(*) FROM T WHERE C_KEY > 0;

UPDATE T SET FLAGS = BIN_OR(FLAGS, 64) WHERE ID <= 10;
COMMIT;
-- Garbage collection of the old record versions
SELECT COUNT(*) FROM T;
COMMIT;
EXIT;

Actual result

PLAN (T INDEX (T_C_KEY))      <- attachment that created the index
PLAN (T NATURAL)              <- new attachment

gfix -v -full:

Summary of validation errors
        Number of index page errors     : 1

firebird.log:

Error: Index 2 is corrupt {missing entries for record 0} in table T (128)

Expected result

  • The index is usable in every attachment.
  • Validation reports no errors.

Additional observations

All results below were checked with gfix -v -full. Each combination was run with the first statement in the new attachment being either the UPDATE or a SELECT ... WHERE C_KEY < 0, and with garbage collection done either by the same attachment or by gfix -sweep. All variants gave the same result.

Change made in a new attachment P_READ_T reads T P_READ_T does not read T (RES = :ID;)
bit 64: 0 → 1 (C_KEY changes) corrupted OK
bit 64: 1 → 0 (C_KEY changes) corrupted OK
other bits only (C_KEY unchanged) OK OK
  • The problem does not depend on the procedure reading computed columns. Reading only ordinary columns of T is enough.
  • Every row whose C_KEY changes in a normal attachment loses its index entries (100 of 100 in one test). Validation reports only the first one.
  • Online validation (fbsvcmgr ... action_validate) did not report the corruption in some runs where gfix -v -full did.
  • The real-world case is an ERP table with about 20 computed columns calling selectable procedures that read the same table. There, a flag bit is set or cleared by ordinary UPDATEs from the application, and the index has to be rebuilt periodically.

Activity

  1. tomaszdubiel18 commented on Sep 29, 2026

    @tomaszdubiel18
    Author

    Hello. Can someone look at this bug?
    Best regards.

  2. AlexPeshkoff commented on Sep 29, 2026

    @AlexPeshkoff
    Member
  3. tomaszdubiel18 commented on Sep 29, 2026

    @tomaszdubiel18
    Author

    "Yes, it is essential — but so is its position in the table. I ran additional variants on
    5.0.4.1812 (same script, checked with gfix -v -full). "Plan" is the plan in the new
    attachment:

    # Variant Plan Validation
    1 Original script NATURAL corrupted
    2 No C_PROC column at all INDEX OK
    3 C_PROC present, procedure does not read T (RES = :ID) INDEX OK
    4 C_PROC is a subquery on T instead of a procedure call INDEX OK
    5 C_KEY computed from base columns only (no C_BIT inside) NATURAL corrupted
    6 Index expression on base columns only (no computed column) INDEX OK
    7 Index on C_BIT (C_PROC defined before C_BIT) NATURAL corrupted
    8 C_PROC defined as the last column INDEX OK
    9 Column order C_BIT, C_PROC, C_KEY; index on C_KEY NATURAL corrupted
    10 Column order C_BIT, C_PROC, C_KEY; index on C_BIT INDEX OK

    So the bug needs all of the following:

    • a computed column calling a stored procedure that reads the same table,
    • that column defined BEFORE the computed column used by the index,
    • an expression index referencing a computed column (one level is enough; nesting
      is not required).

    A subquery on the same table instead of a procedure call does not trigger it, and neither
    does an index expression built from base columns only."

  4. sim1984 commented on Sep 29, 2026

    @sim1984
    Contributor

    What if you create the T_C_KEY index before ALTER PROCEDURE P_READ_T?

    P.S. It is better to avoid creating computed columns that perform a SELECT against anything.

  5. tomaszdubiel18 commented on Sep 29, 2026

    @tomaszdubiel18
    Author

    "The order does not matter — it still reproduces. Results on 5.0.4.1812 (same statements,
    only reordered; "Plan" is the plan in the new attachment, result checked with gfix -v -full):

    Order of statements in the first session Plan Validation
    ALTER PROCEDURE -> insert data -> CREATE INDEX (original script) NATURAL corrupted
    CREATE INDEX (empty table) -> ALTER PROCEDURE -> insert data NATURAL corrupted
    insert data -> CREATE INDEX -> ALTER PROCEDURE NATURAL corrupted
    insert data -> CREATE INDEX -> ALTER PROCEDURE -> ALTER INDEX INACTIVE/ACTIVE NATURAL corrupted
    CREATE INDEX -> ALTER PROCEDURE -> insert data, then the second part NATURAL corrupted
    of the script run in a separate isql process instead of CONNECT

    So what matters is the state of the metadata when a new attachment loads the table:
    the procedure called from C_PROC already reads T. When the procedure does not read T
    at that moment (RES = :ID), the index is used and validation is clean (variant 3 from
    my previous comment)."

  6. tomaszdubiel18 commented on Oct 1, 2026

    @tomaszdubiel18
    Author

    Hello.
    I ordered tests on FB 6.0 for comparison purposes. If it's not helpful for you, you can ignore it, but maybe it will help you with the analysis.
    "Checked on the current master snapshot 6.0.0.2195-f9426e5 (Linux x64, SuperServer).

    1. The plan problem (Problem with using a computed index on a computed column #7945) still exists. Exactly the same variants as on 5.0.4 lose the index (original script, variants 5, 7 and 9 from my earlier comment, and all statement orders from the previous comment).

    2. There is NO index corruption on 6.0. gfix -v -full is clean in all 15 variants and in a stress run: 40 cycles of "first attachment, flip the bit in ~100 rows, GC", about 4000 key changes in total.

    3. The plan problem affects a different set of attachments than on 5.0:

    Attachment 5.0.4 6.0
    B: first one after the creating session is closed NATURAL NATURAL
    B: after executing DML NATURAL NATURAL
    X: new one while B is still connected NATURAL, index cannot be forced INDEX
    Rows without a correct index entry (checked from X) n/a 0
    Y: new one after all attachments disconnected NATURAL NATURAL
    gfix -v -full corrupted OK

    So on 6.0 only the attachment that first loads the table metadata (while it is not cached) fails to match the index. Attachments opened while the metadata is cached match it, and the keys stored by the first attachment are correct.

    This looks consistent with the problem being created during the initial, recursive load of the relation (computed column -> procedure -> the same table). With the shared metadata cache in 6.0 it only affects statement compilation in the attachment doing that load. On 5.0, where every attachment loads its own metadata, every attachment gets an index expression that differs from the one in the creating attachment, and uses it to compute keys as well, which leads to the corruption."

  7. tomaszdubiel18 commented on Oct 7, 2026

    @tomaszdubiel18
    Author

    Hello. Are there chances to introduce the fix into the next Firebird 5.0 release?

  8. AlexPeshkoff commented on Oct 7, 2026

    @AlexPeshkoff
    Member

    Yes, there are chances - but not guarantees.
    Also take into an account that releases take place not weekly and even not monthly.

    PS. Including normal (not AI) description can make that chances grow :-)

  9. tomaszdubiel18 commented on Oct 7, 2026

    @tomaszdubiel18
    Author

    I'm aware of that. What matters to me is to have it in the next official release.
    I will do my best, I will follow @dyemanov recommendations concerning AI reports :-)

  10. tomaszdubiel18 commented on Oct 15, 2026

    @tomaszdubiel18
    Author

    Below are the results of analysis performed by AI.

    Summary

    Root cause

    The call chain was captured with gdb on 5.0.5.1916 using the script from this ticket, during the first query in a new attachment:

    DsqlDmlStatement::dsqlPass -> CMP_compile -> PAR_parse ... -> RelationSourceNode::parse
    -> MET_scan_relation -> scan_relation                      (table T, REL_being_scanned)
       -> PAR_blr (RSR_computed_blr of C_PROC) -> SubQueryNode::parse -> ProcedureSourceNode::parse
       -> MET_lookup_procedure -> MET_procedure -> Routine::parseBlr -> PAR_blr -> Statement::makeStatement
          -> pass2 -> CMP_post_rse -> Optimizer::compile -> Optimizer::compileRelation (T, inside P_READ_T)
          -> BTR_all -> BTR_description -> MET_lookup_index_expression (index T_C_KEY)
    
    1. scan_relation() sets REL_scanned before it parses the fields, and it parses them in the order of RDB$RUNTIME.
    2. While it parses C_PROC, the body of P_READ_T is compiled and optimized. The query on T in that body makes the optimizer fetch the descriptions of T's indexes, so the expression of T_C_KEY is compiled.
    3. At that moment C_KEY and C_BIT are not loaded yet. FieldNode::pass1() cannot replace the computed column with its expression. It leaves a plain field reference and sets csb_reload, the mechanism added for Computed field could be wrongly evaluated as NULL [CORE6351] #6592.
    4. jrd_prc and Function check csb_reload and recompile the routine before it is executed. MET_lookup_index_expression() and MET_lookup_index_condition() ignore the flag, and store the incomplete tree in IndexBlock::idb_expression / idb_condition until the attachment ends.

    In that attachment:

    Patch (v5.0-release)

    --- a/src/jrd/met.epp
    +++ b/src/jrd/met.epp
    @@ -2668,8 +2668,14 @@ void MET_lookup_index_condition(thread_db* tdbb, jrd_rel* relation, index_desc*
     	}
     	END_FOR
     
    +	const bool reload = csb && (csb->csb_g_flags & csb_reload);
     	delete csb;
     
    +	// See the comment in MET_lookup_index_expression()
    +
    +	if (reload)
    +		return;
    +
     	// If there is no existing index block for this index, create
     	// one and link it in with the index blocks for this relation
     
    @@ -2759,8 +2765,17 @@ void MET_lookup_index_expression(thread_db* tdbb, jrd_rel* relation, index_desc*
     	}
     	END_FOR
     
    +	const bool reload = csb && (csb->csb_g_flags & csb_reload);
     	delete csb;
     
    +	// The expression was compiled while the relation is being scanned and it references
    +	// fields whose definitions are not loaded yet (e.g. a computed field calls a procedure
    +	// that reads the same relation). Use it as is, but do not cache it: the expression
    +	// will be compiled again after the relation is scanned completely.
    +
    +	if (reload)
    +		return;
    +
     	// if there is no existing index block for this index, create
     	// one and link it in with the index blocks for this relation

    The incomplete expression is still returned to the caller, which is the optimizer working on the routine being compiled during the scan. It can only match an expression that also contains a plain reference to a not yet loaded computed column. Compiling such an expression sets csb_reload for the routine too, so the routine is recompiled before it is executed. The uncached statement is allocated from an attachment pool, so it stays valid while the routine statement exists and is freed at disconnect. This matches the existing path in the same function when the index block lock cannot be taken.

    Verification (5.0.5.1916)

    Flow of each test:

    1. Attachment A creates the index and stays open.
    2. New attachment B checks the plan and the forced plan, changes the indexed value in 10 rows, commits, and runs select count(*) (garbage collection).
    3. A counts the rows through the index and through C_KEY + 0.
    4. New attachment V repeats the checks, then gfix -v -full is run.
    Variant Without patch: plan in B / A via index vs. expected / gfix With patch
    Script from #9165, variants 5, 7, 9 and both statement orders from the comments NATURAL / 0 vs 10 / index page error INDEX / 10 vs 10 / OK
    Variants 2, 3, 4, 6, 8, 10 from the comments INDEX / OK / OK the same
    Bit 64 changed 1 → 0 NATURAL / error INDEX / OK
    INSERT + DELETE in B NATURAL / 0 vs 1 / gfix OK (damage not detected) INDEX / 1 vs 1 / OK
    Partial index ON T (ID) WHERE C_BIT = 1 partial index not used / 0 vs 10 / gfix OK (not detected) INDEX / 10 vs 10 / OK
    UNIQUE index on a computed column, B inserts a duplicate duplicate accepted duplicate rejected
    PSQL function or packaged procedure instead of P_READ_T; three levels of computed columns NATURAL / 0 vs 10 / error INDEX / OK / OK
    Procedure that searches T by C_KEY (compiled during the scan) NATURAL / 0 vs 10 / error INDEX / OK / OK; procedure results correct
    Garbage collection by gfix -sweep error OK
    Index created or reactivated in a new attachment, also with ParallelWorkers = 4 and 200,000 rows index not usable even there / error INDEX / OK
    Backup and restore, then the same checks on the restored database NATURAL INDEX / OK
    Classic (8 of the variants above) NATURAL, wrong results, gfix OK INDEX / OK
    Original #7945 query on a metadata-only copy of the reporter's database (65 computed columns in NAGL; UDFs replaced by PSQL stubs) PLAN JOIN (N NATURAL, NK INDEX (FK_NAGLKSEF_NAGL)), 0 vs 10 PLAN JOIN (N INDEX (MK_DO_KSEFX), NK INDEX (FK_NAGLKSEF_NAGL)), 10 vs 10

    In the same database, forcing each of the 4 expression indexes in a new attachment fails for 1 of them (MK_DO_KSEFX) without the patch, and for none with the patch.

    Regression checks

    • firebird-qa (master 77426a3), full run without --runslow, SuperServer Release, both builds side by side: 2886 tests, results identical except gh_7141 and gh_8421. Both tests also pass and fail at random without the patch (gh_8421 measures timing).
    • firebird-qa on Debug builds, run the same way with the server under fbguard -forever:
      • the same set of assertions in firebird.log for both builds, all unrelated to the patch (InternalDS.cpp:231, protocol.cpp:2277, dfw.cpp:10799);
      • the tests with a different status either fail in both builds when run again (they trigger these assertions), or pass in both.
    • Debug build with the patch: all variants above pass and no assertions fire.
    • Cost: an index expression is compiled once more, only in an attachment that loads such a table. It costs about 5.8 KB of attachment memory, which is freed at disconnect. Server RSS did not grow after 300 connect/query/update cycles, and the time of the first query did not change.
    • Index expressions on base columns behave as before, including a base column defined after the computed column that calls the procedure.

    Not covered

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions