Compare commits

..
5 Commits
Author SHA1 Message Date
james.maes 16bd4f768f Merge pull request 'perf: skip tag lookups for tables with no tag assignments (forward-port of #67)' (#68) from fix/read-path-statements-develop into develop
maven / build (push) Successful in 7m34s
build / maven (push) Successful in 7m34s
2026-11-10 22:11:02 +00:00
james.maesandClaude Fable 5.2 04d04adc6e perf(security): authorize each distinct parent once per child result page
maven / build (push) Successful in 7m15s
build / maven (push) Successful in 7m15s
ParentRowCustomizer and AttachmentPreviewCustomizer read the parent of
every child row to decide whether the row is visible, so rows that share a
parent repeated the same read: 61 tasks on two parents ran 61 parent reads
(TagLookupStatementBudgetTest), now 2. The answer is kept only for the one
postQuery call, where the session and transaction cannot change.

Also correct the README: the Tags field is selectable.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 15:58:11 -06:00
james.maesandClaude Fable 5.2 de922d7e86 perf(tags): no assignment lookup for tables that have no tags
Core selects the Tags virtual field for every query or get that names no
fields, so every such read of a TAGS-enabled table ran one
collaboration_record_tag statement, including reads made one record at a
time and tables that were never tagged. Measured with 600 added parents
(TagLookupStatementBudgetTest): a 602-row list ran 2 statements, 600 gets
ran 600 and 100 single-record updates ran 100; now 1 probe, then 0.

TaggedTables remembers, per node and per table, whether any assignment
exists (core Memoization registered with ClusterCaches, 30 s by default,
withTagLookupCacheSeconds, 0 disables). Writing an assignment clears it at
once and after commit and publishes memo-invalidate. The probe ignores row
locks and returns no data, so the answer is the same for every session.
Tables with assignments are read as before: one lookup per 500 parents.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 15:58:11 -06:00
james.maes c6b077321c Merge pull request 'fix(upload-journal): accept Amazon Aurora PostgreSQL (full_page_writes=off)' (#65) from fix/aurora-full-page-writes-develop into develop
maven / build (push) Successful in 7m35s
build / maven (push) Successful in 7m35s
2026-11-10 15:27:45 +00:00
james.maesandClaude Fable 5.2 2837f4152a fix(upload-journal): accept Amazon Aurora PostgreSQL (full_page_writes=off)
maven / build (push) Successful in 9m8s
build / maven (push) Successful in 9m8s
The PostgreSQL 17 journal qualification refused every call on Amazon Aurora
PostgreSQL, which reports full_page_writes=off and does not expose the
parameter in its cluster parameter group.

- The settings query gains an eleventh column,
  to_regprocedure('aurora_version()') IS NOT NULL, which is true on Aurora,
  false on stock PostgreSQL 17 and never raises.
- full_page_writes=off is accepted only when that column is true. Stock
  PostgreSQL with full_page_writes=off is still refused, and synchronous_commit,
  fsync, recovery, isolation, time zone and lock timeout are unchanged on Aurora.
- The decision is the pure function tornPagesPrevented, covered by an always-on
  unit test. The opt-in real PostgreSQL 17 suite simulates Aurora with a
  function named aurora_version() and full_page_writes=off on the server.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-09 20:26:14 -06:00
16 changed files with 922 additions and 13 deletions

No files matched your search

+3
View File
@@ -29,6 +29,9 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Fixed
- Reads of a table that has no tag assignment no longer query `collaboration_record_tag`. Core selects the Tags field for every query or get that names no fields, so each of them (including per-record gets and the stored-record read before an update) ran one assignment statement whether or not the table had ever been tagged: 600 gets of an untagged table ran 600 statements, now none. Each node remembers which tables have no assignment (`TaggedTables`, core `Memoization` registered with `ClusterCaches`) for `CollaborationQBitConfig.withTagLookupCacheSeconds` (default 30, 0 disables); writing an assignment clears it on the node and through the ClusterBus. Tables with assignments are read as before, one lookup per 500 parents. No API or schema change. See "What the Tags field costs a read" in the README.
- Browsing one of the qbit's child tables (comments, tasks, record tags, attachments and the other parent-referencing tables) reads each distinct parent once per result page to authorize its rows, instead of once per row: 61 tasks on two parents ran 61 parent reads, now 2. The check itself and its outcome are unchanged.
- The retained upload journal's PostgreSQL 17 qualification accepts Amazon Aurora PostgreSQL, which reports `full_page_writes=off` and does not let a deployment change it. `off` is accepted only when the settings query detects Aurora (`to_regprocedure('aurora_version()') IS NOT NULL`); stock PostgreSQL with `full_page_writes=off` is still refused, and every other requirement is unchanged on Aurora. See `docs/RETAINED-UPLOAD-JOURNAL.md`.
- Bounded STRING custom fields carry QQQ's native `ValueTooLongBehavior.ERROR`, so a write longer than `maxLength` is rejected with a field error and the stored value is preserved, instead of passing through unchecked (the QQQ default `PASS_THROUGH`).
- Persisted help content is reloaded after each committed runtime metadata refresh, so sections and fields created by publishing a custom-field definition receive their stored `helpContent` rows without a restart.
- QBit metadata reports the Maven artifact version from a filtered resource, including release versions.
+10 -1
View File
@@ -409,7 +409,16 @@ size, pending suggestions, and the five most-used approved tags.
`collaborationTags` is a virtual STRING field labeled Tags with possible-value source `collaboration_tag`. `TagCriteriaExpander` supports IN, NOT_IN, EQUALS, NOT_EQUALS, IS_BLANK, IS_NOT_BLANK and CONTAINS_ALL using core `QSubQuery` membership filters. Ordinary quick search matches labels; `#name` matches normalized names. Numeric criteria are tag IDs; string criteria are names. The old ID materializer is removed. Core owns SQL execution or bounded cross-backend materialization; its bound applies to inner rows, not final parent matches.
The runtime provider adds this field and its search entry where TAGS is enabled. It is queryable but not selectable: the overlay has no virtual-field post-query hook and existing host customizers must be preserved.
The runtime provider adds this field and its search entry where TAGS is enabled. It is queryable and selectable (`TagValueProvider` fills it after host customizers have run).
### What the Tags field costs a read
Core selects every selectable virtual field for a query or get that names no fields, so each such read of a table with TAGS enabled runs `TagValueProvider`, including the reads other code makes one record at a time (a get, the stored-record read before an update, a lookup made per row). The provider therefore costs:
- nothing for a table that has no tag assignment. Each node remembers that one fact per table (`TaggedTables`) for `withTagLookupCacheSeconds` (default 30; 0 looks every time) and learns it with one indexed `LIMIT 1` statement;
- one assignment statement and one vocabulary statement per 500 parents for a table that has assignments. Record tags themselves are never cached.
A node that writes an assignment forgets the table's answer at once and again after commit, and publishes core's `memo-invalidate` cluster event (`ClusterCaches`, name `collaboration.taggedTables`). Hosts with a ClusterBus transport see a table's first tag on every node immediately; without one, another node shows it after at most the configured seconds. The same bound covers assignments written outside QQQ. The answer does not depend on the session (the probe ignores row locks and returns no data), so it cannot change which records or tags a caller sees. A failed probe is thrown, not remembered.
## UI API
+1
View File
@@ -5,4 +5,5 @@
No schema change and nothing required of hosts.
- New table action **Create rows from configuration** on Feature Settings. Run it once if you want the effective feature settings visible and editable as rows. It changes no behavior and does nothing once the table has any row. Note that after rows exist they win over later changes to `withDefaultFeatures` or `withTableFeatures` in code, so change those settings in the rows from then on.
- Reads of tables without any tag assignment skip the assignment lookup (`withTagLookupCacheSeconds`, default 30 seconds, 0 restores a lookup on every read). A host running several nodes without a ClusterBus transport shows a table's very first tag on the other nodes after at most that long; later tags on that table show at once, as before.
- New **Setup** widget first on the Collaboration home, and table help text. Hosts that override help on a table keep their own.
+5 -1
View File
@@ -25,7 +25,11 @@ Both owner tables must use the same QQQ backend. Hosts apply DDL through their m
| --- | --- | --- |
| File H2 | Native `h2`, `jdbc:h2:file:`, actual configured connection, writable real transaction, live `WRITE_DELAY=0` | `UploadJournalTable.h2Script()` |
| MySQL 8.4 | Native `mysql`, actual MySQL 8.4 product/version, exact configured URL target and catalog, writable real transaction, both owner tables InnoDB, fixed owner table mappings, journal `utf8mb4_0900_bin` columns/table, single-column unique locator/intake indexes, synchronous commit settings | `UploadJournalTable.mysqlScript()` |
| Aurora MySQL, MariaDB, PostgreSQL, other versions/providers | Refused; no inferred compatibility or durability claim | None |
| PostgreSQL 17 | Native `postgres` (vendor and backend type), actual PostgreSQL 17 product/version, exact configured `jdbc:postgresql://host/catalog` target and catalog, writable real transaction, not in recovery, `synchronous_commit` `on` or `remote_apply`, `fsync=on`, `full_page_writes=on` (see the Aurora rule below), session `TimeZone` UTC, `lock_timeout` from 1 to 5000 ms, READ COMMITTED, both owner tables permanent ordinary tables with fixed mappings, no non-deterministic collation, complete unique indexes on `locator_hash` and `intake_key` | `UploadJournalTable.postgresScript()` |
| Amazon Aurora PostgreSQL 17 | Every PostgreSQL 17 requirement above, except that `full_page_writes=off` is accepted | `UploadJournalTable.postgresScript()` |
| Aurora MySQL, MariaDB, other versions/providers | Refused; no inferred compatibility or durability claim | None |
Aurora rule: stock PostgreSQL must report `full_page_writes=on`, because recovery repairs a partly written page from the full page image in the write-ahead log. Amazon Aurora PostgreSQL reports `full_page_writes=off` and the parameter is not in its cluster parameter group, so a deployment cannot turn it on; its storage layer does not write torn pages, so the protection is not needed there. The qualification therefore accepts `off` only when the same settings query reports `to_regprocedure('aurora_version()') IS NOT NULL`, which is true on Aurora, false on stock PostgreSQL 17 and never raises. Nothing else is relaxed on Aurora: `synchronous_commit`, `fsync`, recovery state, isolation, time zone and lock timeout are checked exactly as on stock PostgreSQL. The detection is a name lookup, so a function named `aurora_version()` on the session's search path of a stock server is reported as Aurora; creating one is a schema change inside the host's trust boundary, like the settings themselves.
The MySQL adapter resolves explicit or native-generated URLs through QQQ `ConnectionManager`. It accepts a single ordinary `jdbc:mysql://host:port/catalog` target with a simple catalog identifier. Proxy/load-balancing URLs, alternate catalogs or table remapping fail closed. Error messages do not echo URLs or credentials. Driver options remain host-owned; TLS and credentials are not changed by the journal.
+1 -1
View File
@@ -21,7 +21,7 @@
<!-- Only the published Redis API package is a permitted cross-qbit boundary. -->
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email.api,com.kingsrook.qbits.redis.api</qbit.guardrails.allowedApiPackages>
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:qbit-email,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-redis</qbit.guardrails.allowedQbitArtifacts>
<revision>1.2.0-RC.1</revision>
<revision>1.2.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -51,6 +51,7 @@ public class CollaborationQBitConfig implements QBitConfig
private Function<String, String> recipientEmailResolver = null;
private QCodeReference userSessionProvider = null;
private String defaultTagMode = "FREE";
private Integer tagLookupCacheSeconds = 30;
private List<String> tagActionTables = List.of();
private List<String> followActionTables = List.of();
private List<String> followChangeTables = List.of();
@@ -325,6 +326,7 @@ public class CollaborationQBitConfig implements QBitConfig
assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors);
}
assertCondition(List.of("FREE", "APPROVED_ONLY").contains(defaultTagMode == null ? "" : defaultTagMode), "defaultTagMode must be FREE or APPROVED_ONLY", errors);
assertCondition(tagLookupCacheSeconds != null && tagLookupCacheSeconds >= 0, "tagLookupCacheSeconds must be 0 (off) or more", errors);
for(List<String> tables : List.of(followActionTables, followChangeTables, tagActionTables, assignActionTables))
{
for(String table : tables)
@@ -550,6 +552,27 @@ public class CollaborationQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** How long a node remembers whether a table has any tag assignment.
*******************************************************************************/
public Integer getTagLookupCacheSeconds()
{
return (tagLookupCacheSeconds);
}
/*******************************************************************************
** Reads of a table without any tag assignment skip the assignment lookup while
** this node remembers that (default 30 seconds; 0 looks every time). A node
** that writes an assignment forgets at once and tells the others over the
** ClusterBus; without a cluster transport another node shows a table's first
** tag after at most this long. Tables that have assignments are always read.
*******************************************************************************/
public CollaborationQBitConfig withTagLookupCacheSeconds(Integer tagLookupCacheSeconds)
{
this.tagLookupCacheSeconds = tagLookupCacheSeconds;
return (this);
}
/*******************************************************************************
** Host tables receiving Add tags and Remove tags actions.
*******************************************************************************/
@@ -33,7 +33,8 @@ import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
/*******************************************************************************
** Identifier-only hints, published after successful commit.
** Identifier-only hints, published after successful commit. A tag assignment
** also clears what this node remembers about untagged tables (TaggedTables).
*******************************************************************************/
public class CollaborationRealtime implements RecordChangeListenerInterface
{
@@ -55,6 +56,7 @@ public class CollaborationRealtime implements RecordChangeListenerInterface
public void onRecordsChanged(RecordChangeEvent change) throws QException
{
var instance = QContext.getQInstance();
java.util.Set<String> newlyTaggedTables = new java.util.HashSet<>();
for(int index = 0; index < change.getRecords().size(); index++)
{
var row = new com.kingsrook.qqq.backend.core.model.data.QRecord(change.getRecords().get(index));
@@ -70,6 +72,13 @@ public class CollaborationRealtime implements RecordChangeListenerInterface
{
continue;
}
if(CollaborationTables.RECORD_TAG.equals(change.getTableName()) && change.getType() != RecordChangeType.DELETE && newlyTaggedTables.add(table))
{
////////////////////////////////////////////////////////////////////////
// the Tags field skips tables known to have no assignment: forget it //
////////////////////////////////////////////////////////////////////////
com.kingsrook.qbits.collaboration.tags.TaggedTables.assignmentsChanged(table, change.getTransaction());
}
String event = switch(change.getTableName())
{
case CollaborationTables.COMMENT -> change.getType() == RecordChangeType.DELETE || row.getValue("deletedAt") != null
@@ -55,6 +55,7 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
List<QRecord> filter(List<QRecord> records, boolean isGet, boolean generateUrls, AttachmentsService service) throws QException
{
List<QRecord> visible = new ArrayList<>();
ParentRecordAccess.ResultSetParents parents = new ParentRecordAccess.ResultSetParents();
for(QRecord record : records)
{
try
@@ -63,7 +64,7 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
{
throw (new QNotFoundException("Not found"));
}
ParentRecordAccess.requireVisibleParent(record.getValueString("tableName"), record.getValueString("recordId"));
parents.requireVisible(record.getValueString("tableName"), record.getValueString("recordId"));
}
catch(QNotFoundException expected)
{
@@ -47,14 +47,25 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDe
** pair, on the native QQQ postgres backend (vendor and backend type "postgres").
** Observation only: no schema migration and no session or server setting is
** changed at runtime.
**
** Amazon Aurora PostgreSQL 17 is the one qualified variant: it is held to every
** rule here except full_page_writes, which Aurora reports as "off" and does not
** let a deployment change (see tornPagesPrevented).
*******************************************************************************/
final class PostgresJournalDatabase
{
static final String VENDOR = "postgres";
private static final String SETTINGS = "SELECT current_setting('server_version_num')::int, current_setting('transaction_read_only'), pg_is_in_recovery(),"
/////////////////////////////////////////////////////////////////////////////////////////////
// Column 11 detects Amazon Aurora PostgreSQL inside the same statement. to_regprocedure //
// returns NULL when no aurora_version() function resolves, so it is true on Aurora, false //
// on stock PostgreSQL 17 and never raises. Calling aurora_version() directly would abort //
// the transaction on stock PostgreSQL, and pg_proc does not list the function on Aurora. //
/////////////////////////////////////////////////////////////////////////////////////////////
static final String SETTINGS = "SELECT current_setting('server_version_num')::int, current_setting('transaction_read_only'), pg_is_in_recovery(),"
+ " current_setting('synchronous_commit'), current_setting('fsync'), current_setting('full_page_writes'), current_setting('TimeZone'),"
+ " (SELECT setting::bigint FROM pg_settings WHERE name='lock_timeout'), current_setting('transaction_isolation'), current_database()";
+ " (SELECT setting::bigint FROM pg_settings WHERE name='lock_timeout'), current_setting('transaction_isolation'), current_database(),"
+ " to_regprocedure('aurora_version()') IS NOT NULL";
private static final String TABLES = "SELECT c.relname, c.relkind, c.relpersistence FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace"
+ " WHERE n.nspname=current_schema() AND c.relname = ANY(?)";
@@ -124,7 +135,8 @@ final class PostgresJournalDatabase
try(ResultSet settings = statement.executeQuery(SETTINGS))
{
if(!settings.next() || settings.getInt(1) / 10000 != 17 || !"off".equals(settings.getString(2)) || settings.getBoolean(3)
|| !Set.of("on", "remote_apply").contains(settings.getString(4)) || !"on".equals(settings.getString(5)) || !"on".equals(settings.getString(6))
|| !Set.of("on", "remote_apply").contains(settings.getString(4)) || !"on".equals(settings.getString(5))
|| !tornPagesPrevented(settings.getString(6), settings.getBoolean(11))
|| !Set.of("UTC", "Etc/UTC").contains(settings.getString(7)) || settings.getLong(8) < 1 || settings.getLong(8) > 5000
|| !"read committed".equals(settings.getString(9)) || !catalog.equals(settings.getString(10)))
{
@@ -147,6 +159,28 @@ final class PostgresJournalDatabase
/*******************************************************************************
** The torn-page rule, as a pure decision over the observed full_page_writes
** value and the Aurora detection column.
**
** Stock PostgreSQL must report full_page_writes "on": after a crash that leaves
** a partly written page, recovery repairs it from the full page image in the
** write-ahead log, so "off" there can lose committed journal rows.
**
** Amazon Aurora PostgreSQL reports full_page_writes "off" (measured on engine
** 17.9: source default, and the parameter is not in the cluster parameter group,
** so a deployment cannot turn it on). Its storage layer does not write torn
** pages, so the protection is not needed there. "off" is therefore accepted
** only when the server is Aurora; every other rule (synchronous_commit, fsync,
** recovery, isolation, time zone, lock timeout) stays as strict on Aurora.
*******************************************************************************/
static boolean tornPagesPrevented(String fullPageWrites, boolean aurora)
{
return ("on".equals(fullPageWrites) || (aurora && "off".equals(fullPageWrites)));
}
/*******************************************************************************
** Permanent (logged) ordinary tables only, deterministic text comparison, and
** complete valid non-partial unique indexes for the journal's two identities.
@@ -81,6 +81,42 @@ public final class ParentRecordAccess
}
}
/*******************************************************************************
** One result set's parent checks: rows that share a parent cost one lookup.
** The session and transaction do not change within the call that creates it,
** so never keep an instance beyond that call.
*******************************************************************************/
public static final class ResultSetParents
{
private final java.util.Map<java.util.List<String>, Boolean> visible = new java.util.HashMap<>();
/*******************************************************************************
** Same outcome as ParentRecordAccess.requireVisibleParent for every row.
*******************************************************************************/
public void requireVisible(String tableName, String recordId) throws QException
{
java.util.List<String> parent = java.util.Arrays.asList(tableName, recordId);
Boolean known = visible.get(parent);
if(known == null)
{
try
{
requireVisibleParent(tableName, recordId);
known = true;
}
catch(QNotFoundException expected)
{
known = false;
}
visible.put(parent, known);
}
if(!known)
{
throw (new QNotFoundException("Not found"));
}
}
}
/*******************************************************************************
** Core record capability checks include parent WRITE locks for implied grants.
*******************************************************************************/
@@ -43,6 +43,7 @@ public class ParentRowCustomizer implements TableCustomizerInterface
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
{
List<QRecord> visible = new ArrayList<>();
ParentRecordAccess.ResultSetParents parents = new ParentRecordAccess.ResultSetParents();
for(QRecord record : records)
{
try
@@ -51,7 +52,7 @@ public class ParentRowCustomizer implements TableCustomizerInterface
{
throw (new QNotFoundException("Not found"));
}
ParentRecordAccess.requireVisibleParent(record.getValueString("tableName"), record.getValueString("recordId"));
parents.requireVisible(record.getValueString("tableName"), record.getValueString("recordId"));
QRecord row = new QRecord(record);
if(row.getValue("deletedAt") != null)
{
@@ -45,16 +45,26 @@ public class TagValueProvider implements VirtualFieldValueProviderInterface
{
/*******************************************************************************
** Batch both assignments and vocabulary; never recursively load parent records.
** A table without any assignment costs no statement: core selects this field
** for every read that names no fields, so untagged tables must stay free.
*******************************************************************************/
@Override
public void fill(QTableMetaData table, String fieldName, List<QRecord> records, QueryInput origin) throws QException
{
if(!TaggedTables.mayHaveAssignments(table.getName(), origin.getTransaction()))
{
for(QRecord record : records)
{
record.setValue(fieldName, null);
}
return;
}
QTableMetaData metadata = QContext.getQInstance().getTable(CollaborationTables.RECORD_TAG).clone();
metadata.withCustomizers(null);
metadata.getSupplementalMetaData().remove(RecordReferenceTableMetaData.NAME);
for(List<QRecord> page : CollectionUtils.getPages(records, 500))
{
List<String> ids = page.stream().map(record -> String.valueOf(record.getValue(table.getPrimaryKeyField()))).distinct().toList();
QTableMetaData metadata = QContext.getQInstance().getTable(CollaborationTables.RECORD_TAG).clone();
metadata.withCustomizers(null);
metadata.getSupplementalMetaData().remove(RecordReferenceTableMetaData.NAME);
QueryInput input = new QueryInput(CollaborationTables.RECORD_TAG).withTransaction(origin.getTransaction()).withFilter(new QQueryFilter()
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("recordId", QCriteriaOperator.IN, ids)
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false));
@@ -0,0 +1,184 @@
/*
* 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.collaboration.tags;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.cluster.ClusterBus;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.cluster.ClusterEvent;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
/*******************************************************************************
** Which host tables have any tag assignment at all. Every table with TAGS
** enabled carries the Tags field, so every read of such a table would otherwise
** look for assignments, including the tables nobody ever tagged.
**
** Only that one fact per table is remembered, never a record's tags: a table
** known to have assignments is always read from the database. The answer is the
** same for every session (the probe ignores row locks and returns no data), so
** it cannot widen or narrow what a caller sees.
**
** Each node keeps its own answers. An assignment written here clears the
** table's answer on this node at once and again after commit, and on the other
** nodes through the ClusterBus (memo-invalidate). The timeout bounds everything
** that event cannot reach: hosts without a cluster transport, writes made
** outside QQQ, and a probe that raced an uncommitted assignment.
*******************************************************************************/
public final class TaggedTables
{
public static final String CACHE_NAME = "collaboration.taggedTables";
private static final int MAX_TABLES = 10_000;
private static final Map<QInstance, Memoization<String, Boolean>> CACHES = ClusterCaches.shared(CACHE_NAME,
new WeakHashMap<QInstance, Memoization<String, Boolean>>(), TaggedTables::clearAll, TaggedTables::clearTable);
/*******************************************************************************
** Static lookups only.
*******************************************************************************/
private TaggedTables()
{
}
/*******************************************************************************
** False only when the table is known to have no assignment. A failed probe is
** thrown, never remembered. With the cache disabled the answer is always true.
*******************************************************************************/
public static boolean mayHaveAssignments(String tableName, QBackendTransaction transaction) throws QException
{
Integer seconds = CollaborationRecords.config().getTagLookupCacheSeconds();
if(seconds == null || seconds <= 0)
{
return (true);
}
return (cache(QContext.getQInstance(), seconds).getResultThrowing(tableName, name -> hasAssignments(name, transaction)).orElse(true));
}
/*******************************************************************************
** An assignment was written for this parent table. Forget the answer now, so
** the writing transaction reads its own assignment, and again after commit, so
** a probe that ran in between and saw nothing cannot outlive the commit.
*******************************************************************************/
public static void assignmentsChanged(String tableName, QBackendTransaction transaction)
{
QInstance instance = QContext.getQInstance();
forget(instance, tableName);
if(transaction != null)
{
transaction.addAfterCommitCallback(() -> forget(instance, tableName));
}
}
/*******************************************************************************
** Clear on this node and tell the others; a transport failure is logged by the
** bus and the timeout covers the nodes that did not hear.
*******************************************************************************/
private static void forget(QInstance instance, String tableName)
{
ClusterBus.publish(instance, new ClusterEvent(ClusterEvent.TYPE_MEMO_INVALIDATE, CACHE_NAME, tableName));
}
/*******************************************************************************
** One indexed row at most, in the caller's transaction, as the same answer for
** every session: no row locks, no parent check and no customizers.
*******************************************************************************/
private static Boolean hasAssignments(String tableName, QBackendTransaction transaction) throws QException
{
QueryInput input = new QueryInput(CollaborationTables.RECORD_TAG).withTransaction(transaction).withFieldNamesToInclude(Set.of("id"))
.withFilter(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName).withLimit(1));
input.setTableMetaData(CollaborationRecords.cleanupMetadata(CollaborationTables.RECORD_TAG));
input.withPermissionBypass("Tag provider asks only whether a table has any assignment.");
return (!new QueryAction().execute(input).getRecords().isEmpty());
}
/*******************************************************************************
** The instance's answers, following the configured timeout.
*******************************************************************************/
private static Memoization<String, Boolean> cache(QInstance instance, int seconds)
{
synchronized(CACHES)
{
Memoization<String, Boolean> cache = CACHES.computeIfAbsent(instance, key -> new Memoization<String, Boolean>(MAX_TABLES).withMayStoreNullValues(false));
cache.setTimeout(Duration.ofSeconds(seconds));
return (cache);
}
}
/*******************************************************************************
** Every instance in this JVM: an extra probe is cheaper than a stale answer.
*******************************************************************************/
private static List<Memoization<String, Boolean>> caches()
{
synchronized(CACHES)
{
return (List.copyOf(CACHES.values()));
}
}
/*******************************************************************************
** ClusterCaches: forget every table.
*******************************************************************************/
private static void clearAll()
{
caches().forEach(Memoization::clear);
}
/*******************************************************************************
** ClusterCaches: forget one table.
*******************************************************************************/
private static void clearTable(String tableName)
{
caches().forEach(cache -> cache.clearKey(tableName));
}
}
@@ -0,0 +1,368 @@
/*
* 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.collaboration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qbits.collaboration.tags.TagEntry;
import com.kingsrook.qbits.collaboration.tags.TaggedTables;
import com.kingsrook.qbits.collaboration.tags.TagsService;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.cluster.ClusterCacheAudit;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceSecurityConfig;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeVoidVoidMethod;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Count the backend queries this qbit adds to ordinary reads of a host table.
** Every backend query is one SQL statement (and one pool checkout) on an RDBMS
** backend, and core selects the Tags field for every read that names no fields:
** a table nobody tagged must not pay for it, and a tagged table pays per page.
**
** Before TaggedTables (1.2.0-RC.2), with 600 added parents: a 602-row list ran
** 2 assignment queries, 600 gets ran 600 and 100 single-record updates ran 100.
*******************************************************************************/
class TagLookupStatementBudgetTest extends PersonalServiceFixture
{
private static final int PARENTS = 600;
private final TagsService tags = new TagsService();
/*******************************************************************************
** 600 more visible parents, and the Tags field every enabled table receives.
*******************************************************************************/
@BeforeEach
void parents() throws Exception
{
List<QRecord> parents = new ArrayList<>();
for(int id = 4; id <= PARENTS + 3; id++)
{
parents.add(new QRecord().withValue("id", id).withValue("title", "Document " + id).withValue("tenant", "a"));
}
MemoryRecordStore.getInstance().insert(new InsertInput("document").withRecords(parents), true);
RuntimeMetaDataOverlay.refresh(instance);
assertThat(instance.getTable("document").getVirtualFields()).containsKey("collaborationTags");
}
/*******************************************************************************
** A table nobody tagged: one probe, then lists, per-record gets and per-record
** updates (which read the stored record first) add nothing at all.
*******************************************************************************/
@Test
void untaggedTableCostsOneProbeThenNothing() throws Exception
{
assertThat(collaborationQueries(this::listWithoutTags)).isEqualTo(Map.of(CollaborationTables.RECORD_TAG, 1L));
assertThat(collaborationQueries(this::listWithoutTags)).isEmpty();
assertThat(collaborationQueries(() ->
{
for(int id = 4; id <= PARENTS + 3; id++)
{
assertThat(new GetAction().executeForRecord(new GetInput("document").withPrimaryKey(id)).getValue("collaborationTags")).isNull();
}
})).isEmpty();
assertThat(collaborationQueries(() ->
{
for(int id = 4; id <= 103; id++)
{
new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", id).withValue("title", "Renamed " + id))));
}
})).isEmpty();
}
/*******************************************************************************
** A tagged table: one assignment lookup and one vocabulary lookup per 500
** parents, however many records the caller asked for, plus the probe whenever
** the node has to ask again.
*******************************************************************************/
@Test
void taggedTableCostsOneLookupPerPage() throws Exception
{
TagEntry tag = tags.create("document", "Boundary", null);
tags.apply("document", 1, List.of(tag.id()), List.of());
tags.apply("document", 504, List.of(tag.id()), List.of());
assertThat(collaborationQueries(() -> assertThat(taggedIds()).containsExactlyInAnyOrder(1, 504)))
.isEqualTo(Map.of(CollaborationTables.RECORD_TAG, 2L, CollaborationTables.TAG, 2L));
ClusterCaches.invalidate(TaggedTables.CACHE_NAME);
assertThat(collaborationQueries(() -> assertThat(taggedIds()).containsExactlyInAnyOrder(1, 504)))
.isEqualTo(Map.of(CollaborationTables.RECORD_TAG, 3L, CollaborationTables.TAG, 2L));
}
/*******************************************************************************
** A table's first assignment shows at once on the node that wrote it, and the
** last removal leaves the field empty again.
*******************************************************************************/
@Test
void firstAssignmentShowsAtOnce() throws Exception
{
listWithoutTags();
TagEntry tag = tags.create("document", "Urgent", null);
tags.apply("document", 2, List.of(tag.id()), List.of());
assertThat(taggedIds()).containsExactly(2);
assertThat(new GetAction().executeForRecord(new GetInput("document").withPrimaryKey(2)).getValueString("collaborationTags")).isEqualTo("Urgent");
tags.apply("document", 2, List.of(), List.of(tag.id()));
listWithoutTags();
}
/*******************************************************************************
** The answer is forgotten when the assignment is written, so its transaction
** reads its own tag (the service already asked again, so the list does not),
** and again when it commits, so a probe that ran in between and could not see
** the assignment yet does not outlive the commit.
*******************************************************************************/
@Test
void commitForgetsAgain() throws Exception
{
listWithoutTags();
TagEntry tag = tags.create("document", "Urgent", null);
QBackendTransaction transaction = new QBackendTransaction();
QContext.setTransaction(transaction);
try
{
tags.apply("document", 2, List.of(tag.id()), List.of());
assertThat(assignmentQueries(() -> assertThat(taggedIds()).containsExactly(2))).isEqualTo(2L);
transaction.commit();
}
finally
{
QContext.clearTransaction();
}
assertThat(assignmentQueries(() -> assertThat(taggedIds()).containsExactly(2))).isEqualTo(3L);
}
/*******************************************************************************
** An assignment this node did not write (another node, or SQL outside QQQ)
** shows when the ClusterBus says so.
*******************************************************************************/
@Test
void anotherNodesAssignmentShowsAfterClusterInvalidation() throws Exception
{
listWithoutTags();
assignOutsideThisNode(tags.create("document", "Elsewhere", null));
listWithoutTags();
ClusterCaches.invalidateKey(TaggedTables.CACHE_NAME, "document");
assertThat(taggedIds()).containsExactly(1);
ClusterCaches.invalidate(TaggedTables.CACHE_NAME);
assertThat(assignmentQueries(() -> assertThat(taggedIds()).containsExactly(1))).isEqualTo(3L);
}
/*******************************************************************************
** Without a cluster transport the timeout is the bound, and 0 turns the
** memory off: every read looks, as before.
*******************************************************************************/
@Test
void timeoutBoundsWhatNoEventReachesAndZeroDisables() throws Exception
{
config.withTagLookupCacheSeconds(1);
listWithoutTags();
assignOutsideThisNode(tags.create("document", "Elsewhere", null));
listWithoutTags();
Thread.sleep(1100);
assertThat(taggedIds()).containsExactly(1);
config.withTagLookupCacheSeconds(0);
assertThat(assignmentQueries(() -> assertThat(taggedIds()).containsExactly(1))).isEqualTo(2L);
List<String> errors = new ArrayList<>();
config.withTagLookupCacheSeconds(-1).validate(instance, errors);
assertThat(errors).contains("tagLookupCacheSeconds must be 0 (off) or more");
}
/*******************************************************************************
** The remembered answer is the same for every session: a caller who may not
** see a table's only tagged record must not hide its tags from one who may.
*******************************************************************************/
@Test
void answerDoesNotDependOnTheCallersRowAccess() throws Exception
{
config.withMirroredSecurityKeyTypes(List.of("tenant"));
RecordReferenceSecurityConfig.of(instance).withMirroredSecurityKeyTypes(List.of("tenant"));
for(var table : instance.getTables().values())
{
CoreCollaboration.reference(table, config);
}
QContext.getQSession().withSecurityKeyValue("allTenants", true);
TagEntry tag = tags.create("document", "Secret", null);
tags.apply("document", 3, List.of(tag.id()), List.of());
assertThat(CollaborationRecords.cleanupQuery(CollaborationTables.RECORD_TAG, new QQueryFilter()).getFirst().getValueString("lockKey_tenant")).isEqualTo("b");
login("alice");
ClusterCaches.invalidate(TaggedTables.CACHE_NAME);
assertThat(assignmentQueries(this::listWithoutTags)).isEqualTo(3L);
QContext.getQSession().getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("b")));
assertThat(new QueryAction().execute(new QueryInput("document")).getRecords()).extracting(row -> row.getValueString("collaborationTags")).containsExactly("Secret");
}
/*******************************************************************************
** Browsing one of this qbit's child tables authorizes each distinct parent
** once, not once per row (61 rows on two parents ran 61 parent reads before).
*******************************************************************************/
@Test
void childRowsSharingAParentCostOneParentLookup() throws Exception
{
List<QRecord> tasks = new ArrayList<>();
for(int id = 1; id <= 61; id++)
{
tasks.add(new QRecord().withValue("id", id).withValue("tableName", "document").withValue("recordId", id == 61 ? "2" : "1")
.withValue("title", "Task " + id).withValue("assigneeUserId", "alice").withValue("createdByUserId", "alice")
.withValue("status", "OPEN").withValue("parentDeleted", false));
}
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.TASK).withRecords(tasks), true);
login("alice", CollaborationPermissions.VIEW);
assertThat(backendQueries(() -> assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.TASK)).getRecords()).hasSize(61)).get("document")).isEqualTo(2L);
}
/*******************************************************************************
** A static cache that is not registered would go stale on the other nodes.
*******************************************************************************/
@Test
void cachesAreRegisteredForClusterInvalidation() throws Exception
{
listWithoutTags();
assertThat(ClusterCacheAudit.unregisteredStaticCaches("com.kingsrook.qbits.collaboration.tags")).isEmpty();
}
/*******************************************************************************
** Every visible parent, none of them tagged.
*******************************************************************************/
private void listWithoutTags() throws Exception
{
assertThat(new QueryAction().execute(new QueryInput("document")).getRecords()).hasSize(PARENTS + 2)
.allSatisfy(row -> assertThat(row.getValue("collaborationTags")).isNull());
}
/*******************************************************************************
** The ids of the visible parents that carry a tag label.
*******************************************************************************/
private List<Integer> taggedIds() throws Exception
{
return (new QueryAction().execute(new QueryInput("document")).getRecords().stream()
.filter(row -> row.getValue("collaborationTags") != null).map(row -> row.getValueInteger("id")).toList());
}
/*******************************************************************************
** Store an assignment for document 1 without any action or listener running.
*******************************************************************************/
private void assignOutsideThisNode(TagEntry tag) throws Exception
{
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.RECORD_TAG).withRecords(List.of(new QRecord()
.withValue("tableName", "document").withValue("recordId", "1").withValue("tagId", tag.id())
.withValue("createdByUserId", "alice").withValue("parentDeleted", false))), true);
}
/*******************************************************************************
** Backend queries against the assignment table while the work runs.
*******************************************************************************/
private long assignmentQueries(UnsafeVoidVoidMethod<Exception> work) throws Exception
{
return (collaborationQueries(work).getOrDefault(CollaborationTables.RECORD_TAG, 0L));
}
/*******************************************************************************
** Backend queries against this qbit's tables while the work runs, by table.
*******************************************************************************/
private Map<String, Long> collaborationQueries(UnsafeVoidVoidMethod<Exception> work) throws Exception
{
Map<String, Long> result = backendQueries(work);
result.keySet().removeIf(table -> !table.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX));
return (result);
}
/*******************************************************************************
** Every backend query while the work runs, by table.
*******************************************************************************/
private Map<String, Long> backendQueries(UnsafeVoidVoidMethod<Exception> work) throws Exception
{
MemoryRecordStore.resetStatistics();
MemoryRecordStore.setCollectStatistics(true);
try
{
work.run();
Map<String, Long> result = new TreeMap<>();
List<AbstractActionInput> inputs = MemoryRecordStore.getActionInputs().get(QueryInput.class);
for(AbstractActionInput input : inputs == null ? List.<AbstractActionInput>of() : inputs)
{
result.merge(((QueryInput) input).getTableName(), 1L, Long::sum);
}
return (result);
}
finally
{
MemoryRecordStore.setCollectStatistics(false);
MemoryRecordStore.resetStatistics();
}
}
}
@@ -0,0 +1,83 @@
/*
* 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.collaboration.attachments.uploadjournal;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The torn-page decision of the PostgreSQL journal qualification, without a
** database: the rule is a pure function of the observed full_page_writes value
** and the Aurora detection column. The real-server cases, including the
** detection column itself, are in the opt-in PostgresUploadJournalTest.
*******************************************************************************/
class PostgresJournalDatabaseTest
{
/*******************************************************************************
** "on" qualifies everywhere; "off" qualifies only on Amazon Aurora PostgreSQL.
*******************************************************************************/
@Test
void fullPageWritesOffIsAcceptedOnlyOnAurora()
{
assertThat(PostgresJournalDatabase.tornPagesPrevented("on", false)).isTrue();
assertThat(PostgresJournalDatabase.tornPagesPrevented("on", true)).isTrue();
assertThat(PostgresJournalDatabase.tornPagesPrevented("off", true)).isTrue();
assertThat(PostgresJournalDatabase.tornPagesPrevented("off", false)).isFalse();
}
/*******************************************************************************
** Anything that is neither "on" nor Aurora's "off" is refused, Aurora or not:
** a missing value, another spelling or an unexpected value.
*******************************************************************************/
@Test
void unknownFullPageWritesValuesAreRefused()
{
for(boolean aurora : new boolean[] { false, true })
{
assertThat(PostgresJournalDatabase.tornPagesPrevented(null, aurora)).isFalse();
assertThat(PostgresJournalDatabase.tornPagesPrevented("", aurora)).isFalse();
assertThat(PostgresJournalDatabase.tornPagesPrevented("OFF", aurora)).isFalse();
assertThat(PostgresJournalDatabase.tornPagesPrevented("ON", aurora)).isFalse();
assertThat(PostgresJournalDatabase.tornPagesPrevented("false", aurora)).isFalse();
}
}
/*******************************************************************************
** The settings query detects Aurora only through to_regprocedure, as its last
** column. It never calls aurora_version() directly, which would abort the
** transaction on stock PostgreSQL.
*******************************************************************************/
@Test
void settingsQueryDetectsAuroraOnlyThroughToRegprocedure()
{
String detection = "to_regprocedure('aurora_version()') IS NOT NULL";
assertThat(PostgresJournalDatabase.SETTINGS).endsWith(", " + detection);
assertThat(PostgresJournalDatabase.SETTINGS.replace(detection, "")).doesNotContain("aurora");
}
}
@@ -81,6 +81,11 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
** dynamic port, random synthetic passwords); the application connects as a
** DML-only role, and the superuser is used only for fixture DDL and fault
** injection. The attachment row is a minimal stand-in, not a full intake fixture.
**
** No Amazon Aurora server is used. The Aurora cases run on this stock PostgreSQL
** 17 server and simulate exactly two things: a function named aurora_version()
** created in the suite's database (so to_regprocedure resolves, as it does on
** Aurora), and full_page_writes turned off on the server (as Aurora reports it).
*******************************************************************************/
@EnabledIfSystemProperty(named = "postgresJournalTests", matches = "true")
class PostgresUploadJournalTest
@@ -107,6 +112,9 @@ class PostgresUploadJournalTest
/*******************************************************************************
** Start only the disposable server with explicit durable settings. pgjdbc
** announces the JVM zone as the session TimeZone, so the suite runs in UTC.
** fsync and full_page_writes are set through ALTER SYSTEM rather than on the
** command line, because a command-line value cannot be changed by a reload and
** the Aurora and durability cases need to turn each one off and back on.
*******************************************************************************/
@BeforeAll
static void start() throws Exception
@@ -115,7 +123,7 @@ class PostgresUploadJournalTest
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
postgres = new GenericContainer<>("postgres:17")
.withEnv("POSTGRES_PASSWORD", ROOT_PASSWORD).withEnv("POSTGRES_DB", DATABASE)
.withCommand("postgres", "-c", "fsync=on", "-c", "synchronous_commit=on", "-c", "full_page_writes=on")
.withCommand("postgres", "-c", "synchronous_commit=on")
.withExposedPorts(5432)
.withCreateContainerCmdModifier(command ->
{
@@ -138,6 +146,8 @@ class PostgresUploadJournalTest
statement.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO " + APP_ROLE);
statement.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO " + APP_ROLE);
}
serverSetting("fsync", "on");
serverSetting("full_page_writes", "on");
}
@@ -174,6 +184,9 @@ class PostgresUploadJournalTest
execute("ALTER ROLE " + APP_ROLE + " RESET synchronous_commit");
execute("ALTER ROLE " + APP_ROLE + " RESET default_transaction_isolation");
execute("ALTER ROLE " + APP_ROLE + " RESET default_transaction_read_only");
serverSetting("fsync", "on");
serverSetting("full_page_writes", "on");
execute("DROP FUNCTION IF EXISTS aurora_version()");
backend = new PostgreSQLBackendMetaData();
backend.setName("postgres_journal");
backend.setDatabaseName(DATABASE);
@@ -440,6 +453,67 @@ class PostgresUploadJournalTest
/*******************************************************************************
** The settings query carries the Aurora detection as its eleventh column. As
** the DML-only application role on a stock server it reports false and does not
** raise, so the transaction stays usable; once aurora_version() resolves (the
** simulated Aurora) it reports true.
*******************************************************************************/
@Test
void settingsQueryDetectsAuroraWithoutRaising() throws Exception
{
assertThat(settingsQueryReportsAurora()).isFalse();
simulateAurora();
assertThat(settingsQueryReportsAurora()).isTrue();
}
/*******************************************************************************
** full_page_writes=off is refused on stock PostgreSQL and accepted only once
** the server is detected as Aurora (simulated); removing the detection refuses
** it again, with no row written by a refused call.
*******************************************************************************/
@Test
void fullPageWritesOffIsAcceptedOnlyOnAurora() throws Exception
{
serverSetting("full_page_writes", "off");
assertUnsupported();
assertThat(count("collaboration_upload_journal")).isZero();
simulateAurora();
ConnectionManager.closeConnectionProviders();
assertThat(journal.prepare(candidate("aurora")).id()).isPositive();
assertThat(count("collaboration_upload_journal")).isEqualTo(1);
execute("DROP FUNCTION aurora_version()");
assertUnsupported();
assertThat(count("collaboration_upload_journal")).isEqualTo(1);
}
/*******************************************************************************
** Aurora (simulated, full_page_writes=off) gets no other allowance: with
** synchronous_commit off, or with fsync off, it is refused like any server.
*******************************************************************************/
@Test
void auroraIsStillRefusedWithoutSynchronousCommitOrFsync() throws Exception
{
simulateAurora();
serverSetting("full_page_writes", "off");
assertThat(journal.prepare(candidate("aurora-baseline")).id()).isPositive();
execute("ALTER ROLE " + APP_ROLE + " SET synchronous_commit='off'");
assertUnsupported();
execute("ALTER ROLE " + APP_ROLE + " RESET synchronous_commit");
serverSetting("fsync", "off");
assertUnsupported();
serverSetting("fsync", "on");
ConnectionManager.closeConnectionProviders();
assertThat(journal.prepare(candidate("aurora-restored")).id()).isPositive();
assertThat(count("collaboration_upload_journal")).isEqualTo(2);
}
/*******************************************************************************
** Physical drift is refused: an unlogged owner table (lost in crash recovery)
** and a non-deterministic collation on a journal column.
@@ -565,6 +639,75 @@ class PostgresUploadJournalTest
/*******************************************************************************
** Make this stock server look like Amazon Aurora PostgreSQL to the detection:
** a function named aurora_version() in the suite's schema. Nothing else about
** Aurora is simulated by this call.
*******************************************************************************/
private void simulateAurora() throws SQLException
{
execute("CREATE FUNCTION aurora_version() RETURNS text LANGUAGE sql AS 'SELECT ''17.9''::text'");
}
/*******************************************************************************
** Run the production settings query as the application role inside one
** transaction, then prove that transaction was not aborted by it.
*******************************************************************************/
private boolean settingsQueryReportsAurora() throws SQLException
{
try(Connection connection = DriverManager.getConnection(appUrl(), APP_ROLE, APP_PASSWORD); Statement statement = connection.createStatement())
{
connection.setAutoCommit(false);
boolean aurora;
try(ResultSet settings = statement.executeQuery(PostgresJournalDatabase.SETTINGS))
{
assertThat(settings.getMetaData().getColumnCount()).isEqualTo(11);
assertThat(settings.next()).isTrue();
aurora = settings.getBoolean(11);
assertThat(settings.wasNull()).isFalse();
}
try(ResultSet stillUsable = statement.executeQuery("SELECT 1"))
{
assertThat(stillUsable.next()).isTrue();
}
connection.rollback();
return (aurora);
}
}
/*******************************************************************************
** Change a reload-level (sighup) setting of the disposable server and wait
** until a new session reports it, so the next pooled session observes it too.
*******************************************************************************/
private static void serverSetting(String name, String value) throws Exception
{
try(Connection connection = root(); Statement statement = connection.createStatement())
{
statement.execute("ALTER SYSTEM SET " + name + "=" + value);
statement.execute("SELECT pg_reload_conf()");
}
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
while(System.nanoTime() < deadline)
{
try(Connection connection = root(); Statement statement = connection.createStatement();
ResultSet setting = statement.executeQuery("SELECT current_setting('" + name + "')"))
{
if(setting.next() && value.equals(setting.getString(1)))
{
return;
}
}
Thread.sleep(100);
}
throw (new IllegalStateException("The disposable server did not apply " + name + "=" + value + "."));
}
/*******************************************************************************
** Prepare a leased manifest whose stored bytes have been verified by owner.
*******************************************************************************/