Compare commits

..
Author SHA1 Message Date
james.maes 7f1b5988af Merge pull request 'fix: a rule read that asks for its own rows no longer recurses' (#43) from fix/reentrant-rule-reload into develop
maven / build (push) Successful in 1m26s
build / maven (push) Successful in 1m26s
2026-11-14 08:23:02 +00:00
james.maesandClaude Fable 5.2 d99db4738f fix: a rule read that asks for its own rows no longer recurses
maven / build (push) Successful in 1m17s
build / maven (push) Successful in 1m17s
Reading the rule rows a node keeps (classifications, active retention rules,
policies) is a query on a secured-data table, and other code runs inside that
query: a virtual field value provider or a post-query customizer that another
QBit or the host put on the table. When that code runs a query with filter
criteria, core validates the operands of classified fields and asks this QBit
for the classification of the field, which needs the rows that are being
read. The version of the rows was recorded only after the read returned, so
the nested question started the read again, and that read asked again:
StackOverflowError, and the same on every later request, since nothing was
ever recorded. One classification row was enough (core runs no value provider
for an empty result).

- System reads of the QBit's own tables (SystemWrites.query, the audit key
  lookup) name the stored fields of the table. Core fills a virtual field only
  for a query that selects it, so no value provider runs inside a system read.
- RuleRows holds one kind of rule rows and is used for all three kinds, which
  had the same shape. A question asked from inside a read is answered from the
  rows held when the read began (none before the first read) and never starts
  another read. The owner's monitor makes that answer private to the reading
  thread: every other thread waits and gets the rows the read returns.
- A read that fails records nothing and clears the reading mark, so the next
  question reads again. The rows held before are not given to a request in
  place of rows that could not be read.
- RetentionRules.active: a question asked from inside a transaction's read of
  its own rule edits is answered from the rules every thread shares instead
  of reading the edits again.

Who can be given the nested answer: only code on the reading thread, inside
SystemWrites.call, where the session is the system session and the resolver
answers FULL without looking at a classification. No field access decision
for a person is made from it; the request that started the read, and every
request on another thread, gets the rows the read returns.

Tests (SecuredDataRuleReloadTest): 9 of the 11 end in StackOverflowError
without the fix. They cover a value provider and a post-query customizer on
the classification, retention rule and policy tables, the answer a nested
question gets in both directions of a change, the session it is asked in, a
second thread waiting for the read, and failed reads (an exception and an
Error) being read again.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 01:56:42 -06:00
6 changed files with 14 additions and 319 deletions

No files matched your search

-4
View File
@@ -4,10 +4,6 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
## [Unreleased]
### Fixed: a reveal and the access-log writer waiting for each other
- A reveal, or the start, end or expiry of a break-glass session, no longer deadlocks with a thread that asks for a classification while it holds another lock of this QBit. The access-log writer is such a thread: it holds the anomaly counters while it inserts a row, and that insert asks for classifications (it counts earlier rows and writes an audit, both queries with criteria). A reveal holds the runtime's monitor and then inserts its own access-log row. When both happened at once, neither finished, and neither did any later request, because every request asks for a classification. A person who opens a record with a masked field and reveals it straight away does both at once.
- The rule rows a node keeps (classifications, active retention rules) have a lock of their own, held only while they are answered or read. Asking for a classification no longer takes the monitor that reveal and break-glass hold for their whole operation.
### Fixed: rule reads that ask for their own rows
- Reading the rule rows a node keeps (classifications, active retention rules, policies) no longer recurses when code that runs inside that read asks for the same rows. Another QBit's virtual field value provider, or a post-query customizer, on a secured-data table that ran a query with filter criteria did exactly that: with one classification row present, every request that needed a classification ended in a `StackOverflowError`.
- The QBit's system reads of its own tables (`SystemWrites.query`, the audit key lookup) name the stored fields of the table, so core runs no virtual field value provider inside them.
+1 -1
View File
@@ -25,7 +25,7 @@
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<!-- QQQ 4.1.0-RC.15 ships UI API 1.6.0, field access and audit value protection -->
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
<revision>0.1.0-RC.7</revision>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -32,12 +32,11 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
** SecuredDataRuntime.MAX_RULE_AGE, and safe against a read that asks for the
** same rows again.
**
** Not synchronized: every call must be made holding the owner's lock (the
** runtime's rule lock for classifications and retention rules, the resolver's
** monitor for policies). That lock is what makes the re-entry answer below
** private to one thread: while a read is running, only the thread running it
** can get in here; every other thread waits for the lock and is given the rows
** that read returns.
** Not synchronized: every call must be made holding the owner's monitor (the
** runtime for classifications and retention rules, the resolver for policies).
** That monitor is what makes the re-entry answer below private to one thread:
** while a read is running, only the thread running it can get in here; every
** other thread waits for the monitor and is given the rows that read returns.
**
** Re-entry: reading rule rows is itself a query, and anything that runs inside
** it (a table customizer, a record-security lookup, a value provider) may ask a
@@ -79,24 +79,10 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
private final transient SecuredDataAccessLogger logger;
private final transient SecuredDataAnomalies anomalies;
/////////////////////////////////////////////////////////////////////////////////
// both are used only while holding ruleLock: one lock for the two, because //
// reading either may ask for the other. //
// //
// It is a lock of its own, never this runtime's monitor. A reveal and the //
// start, end and expiry of a break-glass session hold the runtime's monitor //
// for their whole operation, and every thread asks for a classification all //
// the time (core asks for every field it decides on and for every criterion //
// of every query), including threads that hold another lock of this QBit: the //
// access-log writer inside the anomaly counters, the resolver, the key //
// source. When the question needed the runtime's monitor, such a thread and //
// a reveal that needed its lock next waited for each other for ever. //
// //
// ruleLock is held only while rule rows are answered or read. A read is a //
// query made as the system: it takes none of the QBit's other locks, so no //
// thread holding ruleLock ever waits for one of them. //
// both are used only inside methods synchronized on this runtime: one monitor //
// for the two, because reading either may ask for the other //
/////////////////////////////////////////////////////////////////////////////////
private final transient Object ruleLock = new Object();
private final transient RuleRows classificationRows = new RuleRows();
private final transient RuleRows classificationRows = new RuleRows();
private final transient RuleRows activeRetentionRules = new RuleRows();
@@ -246,13 +232,10 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
** next call reads again. A question asked from inside the read itself (see
** RuleRows) is answered with the rules held when the read began.
*******************************************************************************/
public List<QRecord> activeRetentionRules()
public synchronized List<QRecord> activeRetentionRules()
{
synchronized(ruleLock)
{
return (activeRetentionRules.get(retentionVersion(), () -> SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true)))));
}
return (activeRetentionRules.get(retentionVersion(), () -> SystemWrites.query(SecuredDataTables.RETENTION_RULE, new QQueryFilter()
.withCriteria(new QFilterCriteria("is_active", QCriteriaOperator.EQUALS, true)))));
}
@@ -306,12 +289,9 @@ public class SecuredDataRuntime implements QSupplementalInstanceMetaData, AutoCl
** inside the read itself (see RuleRows) is answered with the rows held when
** the read began, and never starts another read.
*******************************************************************************/
List<QRecord> classificationRows()
synchronized List<QRecord> classificationRows()
{
synchronized(ruleLock)
{
return (classificationRows.get(classificationVersion(), () -> SystemWrites.query(SecuredDataTables.CLASSIFICATION, new QQueryFilter())));
}
return (classificationRows.get(classificationVersion(), () -> SystemWrites.query(SecuredDataTables.CLASSIFICATION, new QQueryFilter())));
}
@@ -1,271 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.secureddata;
import java.io.Serializable;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.secureddata.breakglass.BreakGlassService;
import com.kingsrook.qbits.secureddata.metadata.SecuredDataTables;
import com.kingsrook.qbits.secureddata.reveal.RevealResult;
import com.kingsrook.qbits.secureddata.reveal.RevealService;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEvent;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessEventType;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessOutcome;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.CLOCK;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.insert;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.logs;
import static com.kingsrook.qbits.secureddata.SecuredDataTestSupport.policyRow;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
/*******************************************************************************
** A reveal, and the start, end and expiry of a break-glass session, hold the
** runtime's monitor from their first check to their last write, so that two of
** them cannot both pass a check (the daily allowance, one active session).
** Every other thread asks this QBit for a classification all the time: core
** asks for every field it decides on and for every filter criterion of every
** query, including the queries made inside this QBit's own writes.
**
** When that question needed the same monitor, a thread that asked it while
** holding another of this QBit's locks could never be answered during a
** reveal that was about to need that lock: the access-log writer holds the
** anomaly counters' monitor while it inserts a row (the insert counts earlier
** rows and writes an audit, both queries with criteria), and a reveal inserts
** its own access-log row. Reveal: runtime, then counters. Writer: counters,
** then runtime. Both waited for ever, and so did every later request, since
** every request asks for a classification.
**
** The rule rows now have a lock of their own that is held only while they are
** read. These tests run on a relational database (H2 in PostgreSQL mode, with
** the audit tables), where an insert really writes its audit. Each parks one
** thread inside the operation, holding the runtime's monitor, and requires
** the access-log writer and a classification question on other threads to
** finish meanwhile; then the parked operation finishes too. Without the fix
** the writer does not finish and the test fails after WAIT_SECONDS.
*******************************************************************************/
@Timeout(180)
class SecuredDataLockOrderTest
{
private static final long WAIT_SECONDS = 20;
private final ExecutorService threads = Executors.newCachedThreadPool(work ->
{
Thread thread = new Thread(work, "lock-order-test");
thread.setDaemon(true);
return (thread);
});
private QInstance instance;
private SecuredDataRuntime runtime;
private QSession person;
private QTableMetaData reading;
private Serializable pk;
private final CountDownLatch parked = new CountDownLatch(1);
private final CountDownLatch resume = new CountDownLatch(1);
private final AtomicReference<Thread> working = new AtomicReference<>();
private final AtomicBoolean heldTheRuntimesMonitor = new AtomicBoolean();
/*******************************************************************************
** Let a parked thread go, then close; a runtime whose threads are stuck was
** already taken out of the list (see abandon).
*******************************************************************************/
@AfterEach
void cleanup()
{
resume.countDown();
threads.shutdownNow();
RetentionTestSupport.close();
}
/*******************************************************************************
** A reveal in progress holds up neither the access-log writer nor a question.
*******************************************************************************/
@Test
void theAccessLogWriterAndAClassificationQuestionFinishWhileARevealIsInProgress() throws Exception
{
setup();
Future<RevealResult> reveal = parkInside(() -> new RevealService().reveal("reading", pk, "notes", null, "Synthetic reveal; lock order test."));
othersFinishMeanwhile();
resume.countDown();
assertThat(reveal.get(WAIT_SECONDS, TimeUnit.SECONDS).value()).isEqualTo("test-notes");
assertThat(logs()).extracting(row -> row.getValueString("event_type")).contains("EXACT_MATCH_FILTER", "REVEAL");
assertThat(runtime.resolver().isRevealActive("reading", "notes", pk)).isTrue();
}
/*******************************************************************************
** The same for the start of a break-glass session, which holds the same monitor.
*******************************************************************************/
@Test
void theAccessLogWriterAndAClassificationQuestionFinishWhileBreakGlassStarts() throws Exception
{
setup();
person.getPermissions().add(BreakGlassService.START + ".hasAccess");
Future<QRecord> started = parkInside(() -> new BreakGlassService().start("Synthetic break-glass session; lock order test.", 5, "reading", DataClassification.PHI));
othersFinishMeanwhile();
resume.countDown();
assertThat(started.get(WAIT_SECONDS, TimeUnit.SECONDS).getValueString("status")).isEqualTo("ACTIVE");
assertThat(logs()).extracting(row -> row.getValueString("event_type")).contains("EXACT_MATCH_FILTER", "BREAK_GLASS_START");
}
/*******************************************************************************
** A relational instance with audit tables; a PII field one policy makes
** revealable to the person; one row; and one search of that field waiting in
** the access-log queue, as it waits after a person filters a list by it.
*******************************************************************************/
private void setup() throws Exception
{
instance = RetentionTestSupport.setup();
runtime = SecuredDataRuntime.current();
person = QContext.getQSession();
reading = instance.getTable("reading");
insert(SecuredDataTables.POLICY, policyRow("secured_data.admin", "PII", "REVEALABLE"));
pk = RetentionTestSupport.reading(120).getValue("id");
runtime.resolver().onAccess(new FieldAccessEvent().withType(FieldAccessEventType.EXACT_MATCH_FILTER).withOutcome(FieldAccessOutcome.MATCH)
.withTableName("reading").withFieldNames(List.of("notes")).withActionName("query").withRecordCount(1)
.withSearchValueFingerprints(List.of(runtime.resolver().fingerprintSearchValue("known"))));
assertThat(logs()).as("the search is queued, not yet written").extracting(row -> row.getValueString("event_type")).doesNotContain("EXACT_MATCH_FILTER");
}
/*******************************************************************************
** Run the operation as the person on another thread and return once it is
** parked at its first reading of the clock, which both operations make
** inside their synchronized block.
*******************************************************************************/
private <T> Future<T> parkInside(Callable<T> operation) throws Exception
{
CLOCK.beforeInstant = () ->
{
if(Thread.currentThread() == working.get() && parked.getCount() == 1)
{
heldTheRuntimesMonitor.set(Thread.holdsLock(runtime));
parked.countDown();
try
{
resume.await();
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
}
}
};
Future<T> future = threads.submit(() ->
{
QContext.init(instance, person);
try
{
working.set(Thread.currentThread());
return (operation.call());
}
finally
{
QContext.clear();
}
});
assertThat(parked.await(WAIT_SECONDS, TimeUnit.SECONDS)).as("the operation read the clock").isTrue();
assertThat(heldTheRuntimesMonitor).as("the operation is parked holding the runtime's monitor").isTrue();
return (future);
}
/*******************************************************************************
** While the operation is parked: the access-log writer drains its queue (what
** its worker does on every tick), and a thread that holds no lock is told
** the classification of a field and the active retention rules.
*******************************************************************************/
private void othersFinishMeanwhile() throws Exception
{
Future<?> writer = threads.submit(() -> runtime.logger().flush());
Future<DataClassification> question = threads.submit(() ->
{
QContext.init(instance, person);
try
{
runtime.activeRetentionRules();
return (SecuredDataClassifications.effective(reading, "notes"));
}
finally
{
QContext.clear();
}
});
try
{
writer.get(WAIT_SECONDS, TimeUnit.SECONDS);
assertThat(question.get(WAIT_SECONDS, TimeUnit.SECONDS)).isEqualTo(DataClassification.PII);
}
catch(TimeoutException e)
{
abandon();
fail("A thread waited " + WAIT_SECONDS + " seconds for the monitor the parked operation holds (writer done: " + writer.isDone() + ", question done: " + question.isDone()
+ "): the operation needs the lock that thread holds next, so neither would ever finish");
}
}
/*******************************************************************************
** Threads of this runtime are stuck holding its locks: closing it would wait
** for them, so it is left to the daemon threads instead of being closed.
*******************************************************************************/
private void abandon()
{
SecuredDataTestSupport.RUNTIMES.remove(runtime);
}
}
@@ -234,7 +234,6 @@ final class SecuredDataTestSupport
*******************************************************************************/
static void close()
{
CLOCK.beforeInstant = null;
RUNTIMES.forEach(SecuredDataRuntime::close);
RUNTIMES.clear();
QContext.clear();
@@ -249,9 +248,6 @@ final class SecuredDataTestSupport
{
Instant now = NOW;
/** Run on the reading thread before every reading of the clock; a test parks a thread at a known point with it. */
volatile Runnable beforeInstant;
/*******************************************************************************
@@ -279,11 +275,6 @@ final class SecuredDataTestSupport
*******************************************************************************/
public Instant instant()
{
Runnable hook = beforeInstant;
if(hook != null)
{
hook.run();
}
return (now);
}
}