Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e78182985f |
No files matched your search
@@ -1,7 +0,0 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -42,7 +42,7 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.1.1`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-SNAPSHOT`; build the matching Core integration branch first (see `docs/CORE-DEPENDENCY.md`). Public RC.21 lacks required rollback callbacks.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
@@ -6,8 +6,6 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
### Added
|
||||
|
||||
- Attachment lifecycle SPI (`AttachmentLifecyclePolicy`, `CollaborationQBitConfig.withAttachmentLifecycle`) for retention, registration and mandatory access events; durable upload journal (`collaboration_upload_journal`, H2/MySQL/PostgreSQL), upload recovery and existing-attachment adoption processes, durable cleanup queue with operator settlement, verified/authenticated delivery (`AttachmentDownload`, `AttachmentContentIntegrityException`), media policy, aggregate guard and bounded attachment pagination. See `docs/ATTACHMENT-LIFECYCLE.md`, `docs/ATTACHMENT-RECOVERY.md`, `docs/RETAINED-UPLOAD-JOURNAL.md`, `docs/ATTACHMENT-PAGINATION.md`.
|
||||
- Requires a core whose `QBackendTransaction` provides `supportsRollbackCallbacks()` and `addAfterRollbackCallback(Runnable)` (not in 4.1.0-RC.21); see `docs/CORE-DEPENDENCY.md`.
|
||||
- Feature Settings table action **Create rows from configuration**: writes the code configuration out as editable `collaboration_feature_setting` rows (insert-only, only when the table is empty, no change in behavior).
|
||||
- Setup card on the Collaboration home: each record feature, whether it is on, for which tables, and where that comes from.
|
||||
- Host-neutral QBIT help bundle covering tables, fields, sections, built-in actions, the app, widgets and possible-value sources; core registration and `HelpBundleAssertions` coverage replace the temporary help loader.
|
||||
@@ -15,7 +13,6 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
### Changed
|
||||
|
||||
- UI API extension collaboration 1.2.0, additive (#54): presence viewers carry `connections` (live leases merged into the viewer), snapshots carry `leaseCount`, and `GET /presence?perConnection=true` adds `leases`, one viewer per connection with an opaque `connectionId`, `mode`, `fieldName`, `sessionId` and `since`. `presence.changed` events always carry `leaseCount` and `leases`. Leases keep an opaque HMAC-SHA256 session id (secret `QQQ_PRESENCE_SESSION_SECRET` or random per process), never the session uuid, which is a bearer credential. `PresenceRegistry.connectionCounts()` counts leases next to the unchanged user `counts()`; the Who's here card shows both.
|
||||
- UI API extension collaboration 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and published presence request/snapshot/viewer schemas to resolve dangling references. Retain CollaborationErrorV1 as deprecated.
|
||||
|
||||
- Upgrade the default QQQ BOM to `4.1.0-RC.7` from Maven Central.
|
||||
@@ -29,12 +26,6 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
### Fixed
|
||||
|
||||
- Favorites and recents accept a table whose custom permission checker tells a read from a write by the type of the action input. `TargetResolver` asked whether a table is readable with an untyped `AbstractActionInput`; such a checker refuses an input it does not recognise, so `POST /recents` (which the UI sends on every list and record screen) and starring answered `404 Not found` for people the checker lets read the table. The read is now asked as the query it stands for, as `ParentRecordAccess` already asks with a get. Who may read a table is unchanged.
|
||||
- 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.
|
||||
- `presence.changed` events and presence snapshots always carry `viewers`, sending `[]` once the last viewer leaves, as the schema requires (#52).
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# QBit Collaboration
|
||||
|
||||
> **Version 1.0.** Published to the KOF22 Gitea Maven registry (`com.kingsrook.qbits`). Requires QQQ Core `4.1.0-RC.22` or later. See [current status](docs/QBIT-RELEASE-STATUS.md).
|
||||
|
||||
|
||||
Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and core-integrated tag filtering/search. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g add the custom-field designer and ephemeral replicated presence. Runtime overrides and TAGS mode settings resolve through core.
|
||||
|
||||
Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md).
|
||||
@@ -31,10 +28,10 @@ Comment parents list the selected thread's root and replies. Feature-setting sco
|
||||
## Requirements
|
||||
|
||||
- Java 21.
|
||||
- QQQ `4.1.0-SNAPSHOT` built from the compatible Core integration branch; see [Core dependency setup](docs/CORE-DEPENDENCY.md). Middleware Javalin is a compile dependency. Public RC.21 lacks the required rollback API.
|
||||
- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use memory, H2 and disposable PostgreSQL/MySQL containers.
|
||||
- QQQ `4.1.0-RC.7` from Maven Central; middleware Javalin is a compile dependency.
|
||||
- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use the memory backend.
|
||||
- Attachments require `qbit-object-storage:0.1.0-RC.2` (a pinned dependency), installed with a PRIVATE bucket alias matching `attachmentsBucketName`. Configure its connection and credentials in the host; this qbit does not provision buckets.
|
||||
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use the same matching Core snapshot.
|
||||
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-RC.7`.
|
||||
|
||||
## Followers and secured data
|
||||
|
||||
@@ -57,7 +54,7 @@ Comment bodies remain user content; realtime field-name filtering is handled by
|
||||
## Demo server
|
||||
|
||||
`CollaborationDevServer` is a disposable, memory-backed Sales CRM for trying the
|
||||
Next dashboard and taking screenshots. It needs Java 21 and the matching Core integration snapshot. No database, Docker or MinIO is needed. It binds
|
||||
Next dashboard and taking screenshots. It needs Java 21 and QQQ `4.1.0-RC.7` from Maven Central. No database, Docker or MinIO is needed. It binds
|
||||
to `127.0.0.1`; stopping the JVM discards edits and uploaded attachments.
|
||||
|
||||
Build the Next static export in `qqq-frontend-next`:
|
||||
@@ -409,20 +406,11 @@ 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 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.
|
||||
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.
|
||||
|
||||
## UI API
|
||||
|
||||
The producer registers extension `collaboration` version `1.2.0` at
|
||||
The producer registers extension `collaboration` version `1.1.0` at
|
||||
`/qqq/v1/qbit/collaboration`. It uses the signed-in user's session and existing
|
||||
services. Disable registration with `new CollaborationQBitConfig().withUiApi(false)`.
|
||||
Requires QQQ `4.1.0-RC.15` and UI API `1.6.0`. Operations carry stable operationIds,
|
||||
@@ -733,7 +721,7 @@ Enable `RecordFeature.PRESENCE` on the table. The typed endpoints below
|
||||
| --- | --- |
|
||||
| `POST /presence` | `{connectionId, table, pk?, mode: "VIEWING"|"EDITING", fieldName?}` |
|
||||
| `DELETE /presence` | `{connectionId, table, pk?}` |
|
||||
| `GET /presence?table=...&pk=...&perConnection=true` | Omit pk for a table snapshot; `perConnection` is optional |
|
||||
| `GET /presence?table=...&pk=...` | Omit pk for a table snapshot |
|
||||
|
||||
Session identity supplies userId and displayName. Every call rechecks table READ and record
|
||||
GetAction visibility. Disabled features and denied/missing parents return the same 404. A
|
||||
@@ -745,17 +733,6 @@ sweep and expiry checks on reads. The cap is 50,000 leases per pod: new targets
|
||||
a warning while existing leases can renew. Tabs merge per user; EDITING wins and the latest
|
||||
editor supplies focus. Nothing is persisted, including directory rows.
|
||||
|
||||
Per-connection presence (extension 1.2.0, #54): each merged viewer carries `connections`, the
|
||||
number of live leases (tabs, terminals) behind it, and every snapshot carries `leaseCount`, so
|
||||
two terminals of one user read as one person with two connections. `perConnection=true` adds
|
||||
`leases`, one viewer per lease with an opaque `connectionId` (`c` and the first 16 hex digits of
|
||||
SHA-256 of `userId`, a newline and the client connection id), its `mode`, `fieldName` (record
|
||||
targets only), `sessionId` and `since`, the lease start. `presence.changed` events always carry
|
||||
`leaseCount` and `leases`. Session uuids are bearer credentials, so a lease never stores or
|
||||
publishes one: it keeps `sessionId`, `s` and the first 16 hex digits of HMAC-SHA256 of the
|
||||
session uuid under `QQQ_PRESENCE_SESSION_SECRET` (system property or environment; set the same
|
||||
value on every pod so ids agree) or else a random per-process secret. `PresenceRegistry.counts()` still counts users; `connectionCounts()` counts leases.
|
||||
|
||||
Every heartbeat/leave replicates over core's bus on `instance:presence`, a channel refused by
|
||||
core client subscriptions. `presence.changed` is emitted to record and table channels. Table
|
||||
snapshots aggregate users without record ids or focused field names; those stay on record
|
||||
@@ -764,5 +741,5 @@ refetch after reconnecting.
|
||||
|
||||
The initial overlay refresh starts the registry on every pod, including those without local
|
||||
viewers. Endpoints also initialize it lazily. At shutdown call `PresenceRegistry.close(instance)`
|
||||
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user and
|
||||
connection counts per table, and core record-feature summaries include the presence snapshot.
|
||||
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user counts
|
||||
per table, and core record-feature summaries include the presence snapshot.
|
||||
@@ -5,5 +5,4 @@
|
||||
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.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Attachment lifecycle extension
|
||||
|
||||
Collaboration owns standard attachment storage and record access. A host can configure `CollaborationQBitConfig.withAttachmentLifecycle(new QCodeReference(HostPolicy.class))` to enforce retention, registration and mandatory access-event persistence. `HostPolicy` extends `AttachmentLifecyclePolicy`. Collaboration has no dependency on Record Files, generation, scripting or a particular event store.
|
||||
|
||||
The default policy preserves legacy behavior for ordinary attachments. Invalid policy loading fails before storage mutation. This is a trusted Java extension, never an entry point for untrusted user scripts. Policy implementations must be safe for concurrent requests and may not depend on per-instance state surviving between operations.
|
||||
|
||||
## Contract
|
||||
|
||||
| Hook | Timing and meaning |
|
||||
| --- | --- |
|
||||
| `isRetained(attachment)` | Called before object storage during intake with complete metadata but no generated id. Its result is persisted in the server-controlled `retained` field. Also consulted for legacy rows during deletion and display hints. A persisted true value always wins. |
|
||||
| `beforeStored(attachment)` | Called with canonical metadata and actual checksum immediately before the first object PUT, after matching retry replay returns. Retained files default to refusal; a mandatory provider commits storage intent independently. Retained uploads reject ambient transactions. |
|
||||
| `afterStored(attachment)` | Called after object and attachment metadata insertion, before upload returns. Register the retained identity and ingestion status. Failure preserves the object and inserted row for reconciliation when the enclosing transaction permits it. |
|
||||
| `beforeAccess(attachment, inline)` | Called after parent authorization, before signing either standard preview or download URLs. Throw to deny. Runtime policy failures are converted to checked failures so preview code cannot silently swallow them. |
|
||||
| `beforeMetadataChange(attachment, changes)` | Called after parent/ownership checks, before applying filename/description changes. The second record contains id and changed fields only. This represents an intent, not a committed change. |
|
||||
|
||||
Callbacks receive record copies; scalar attachment metadata is the contract. They must not mutate the passed records to influence storage or authorization. They may throw `QException` to fail the operation. A failed registration must remain inaccessible through the policy until reconciliation succeeds; absence of a retained registry entry must never mean unrestricted access.
|
||||
|
||||
## Persistent retention
|
||||
|
||||
`collaboration_attachment.retained` is a required boolean with false default for existing ordinary attachments. Apply the generated PostgreSQL schema migration through the host's normal migration tooling before installing this version. The migration is `ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS retained BOOLEAN NOT NULL DEFAULT FALSE`. It does not automatically classify pre-existing files; any historical backfill requires a deliberate host migration.
|
||||
|
||||
There is no public API to clear retention. Direct user table writes remain denied. Deletion first authorizes and checks retention for the complete selection; it checks retention again immediately before storage deletion. Parent cleanup tombstones retained attachment rows and excludes completed tombstones from subsequent pages. The original bytes remain. The ordinary parent-based UI remains inaccessible after parent deletion; a retained-history view must supply its own preserved authorization policy. This extension does not grant archive access.
|
||||
|
||||
Removing the configured provider cannot restore deletion of rows already marked retained. The no-op provider also refuses signed URLs and metadata edits for those retained rows. Replacing a provider is a trusted host operation: the replacement must still enforce audit and quarantine for every row marked retained, even if its table is no longer configured for new retention.
|
||||
|
||||
Attachment DTOs expose separate `canEdit` and `canDelete` hints. Both require current ownership/admin and attach capability; retention disables deletion. Existing constructor overloads preserve source compatibility. The server rechecks all permissions on mutation. The old `canDelete` flag must no longer control client rename/description actions.
|
||||
|
||||
## Verified content for integrations
|
||||
|
||||
`AttachmentsService.readContent(attachmentId, maximumBytes)` authorizes the actual parent/current actor, enforces both content-read policy phases, validates stored length and SHA-256, closes storage on all paths and returns immutable `AttachmentContent(attachmentId, fileName, contentType, sha256, bytes)`. No storage coordinates or signed URL escape. The caller chooses a limit up to 20 MiB; ordinary large-file download remains a separate streaming/presigned path. No bytes leave this method before integrity verification and the result audit hook succeed.
|
||||
|
||||
`beforeContentRead(attachment)` must authorize and durably record the attempt before storage opens. `afterContentRead(attachment, bytesRead)` must record verified server-read completion before returning content. Both default to refusal for retained files until the lifecycle provider implements them. These facts are distinct from URL issuance, user download, printer spooling and physical output.
|
||||
|
||||
## Retryable intake
|
||||
|
||||
An upload overload adds `intakeKey` and `manifestSha256` after the ordinary upload arguments. Both are server-coordinator SHA-256 identifiers. The owner additionally hashes actual authenticated actor and canonical parent into the storage scope; never trust a client key as authority. The intake fingerprint includes the caller's manifest, filename, declared type, description, actual length and actual content hash. Every retry reads and checks its supplied bytes. A matching attachment is returned and its idempotent registration hook is retried; changed inputs conflict.
|
||||
|
||||
This path rejects ambient transactions. It conditionally creates a deterministic storage key with fingerprint metadata and never overwrites or deletes an uncertain original. If storage succeeded but metadata insertion failed, the next attempt verifies the existing bytes/fingerprint and completes registration. Host migration must add nullable `intake_key`/`intake_fingerprint` columns and the unique nonnull intake-key index from `CollaborationSchema`. That index is the database race boundary; sequential memory fixtures do not prove its concurrency behavior. The calling coordinator still owns durable request reservation, lifecycle and reconciliation scheduling.
|
||||
|
||||
Retryable files are bounded to 20 MiB and use a conditional single-object PUT. The inspected object-storage multipart implementation does not carry conditional-create semantics through completion; it is deliberately not used here. Larger retryable originals need a separately qualified conditional multipart path before this bound changes. This is an advertised intake limit, not a claim of unlimited file sizes or storage immutability. Real storage conditional behavior, object version/lock and backup retention still require provider acceptance.
|
||||
|
||||
## Atomic display metadata auditing
|
||||
|
||||
`beforeMetadataChange` records an independent attempt. Before the update, `validateMetadataTransaction(attachment, transaction)` validates the actual transaction and audit backend; retained files default to denial. After the update, `afterMetadataChange(attachment, changes, transaction)` inserts the success event into that same transaction and also defaults to denial for retained files. The provider must not commit or close the supplied transaction. Reject no-op transactions before mutation; memory tests only establish callback behavior.
|
||||
|
||||
When no caller transaction exists, Collaboration opens, commits, rolls back and closes the owner transaction. If a caller supplied a transaction, that caller owns completion and must roll back on any propagated failure. Successful operation and its event become durable together on commit. Record Files must qualify this with a real supported JDBC backend; this generic owner SPI cannot prove provider durability.
|
||||
|
||||
## Durability boundaries and remaining integration
|
||||
|
||||
The callback is not an audit database. Before returning from `beforeAccess`, a mandatory audit provider must have durably committed its intent independently of a caller transaction that may later roll back. An event means URL issuance was requested, not completed download or physical printing. Storage signing can still fail after the intent is recorded. Signed URLs can be reused until expiration; each use is not observed by Collaboration. Storage access telemetry or a serving gateway is required for transfer events.
|
||||
|
||||
Retained originals are never deleted when metadata insertion fails; ordinary non-retained failed inserts retain legacy cleanup. A committed STORAGE_WRITE_ATTEMPT can identify an orphan even when no attachment row was inserted.
|
||||
|
||||
An object put and QQQ metadata insert are not an atomic transaction. If a surrounding transaction rolls back after object creation, reconciliation must recover an orphaned object. The retained-only journal integration below provides explicit registration recovery; it does not provide malware scanning, WORM storage protection, bucket lifecycle protection or backup verification. Those remain deployment requirements.
|
||||
|
||||
QQQ record-reference write validation issues an internal GetAction using global metadata. Attachment previews use the existing `QueryOrGetInputInterface.getShouldGenerateDisplayValues()` contract: visibility filtering always runs, while URL generation runs only when display values were requested. Core validation GETs default to false; the standard middleware's display GET/query executors explicitly request true. This avoids incidental signing during validation without removing validators or modifying QQQ core. Trusted callers requesting presentation URLs directly through GetAction/QueryAction must explicitly enable display values; `AttachmentsService.downloadUrl` remains an explicit authorized access operation.
|
||||
|
||||
## Aggregate visibility policy
|
||||
|
||||
The optional configuration-only `requiresPinnedAggregateParent()` hook defaults to false. Owners whose parent visibility relies on dynamic archive/project authorization opt in. AttachmentAggregateGuard preserves the normal collaboration permission checker and additionally requires Count/Aggregate inputs to contain one literal top-level AND equality for both `tableName` and `recordId`. It checks current native parent visibility before SQL; joins, set-operation filters, OR roots, missing and conflicting pins are rejected. It does not grant view access.
|
||||
|
||||
Opt-in table metadata hides generic TABLE_COUNT and QUERY_STATS capabilities in both instance and table metadata. The current frontend's use-filter-setup hook already skips totals when TABLE_COUNT is absent and represents them as unknown; record listings and the attachment panel remain available. Native CountAction still permits an authorized pinned count. Do not substitute zero for an unavailable total or issue an unpinned aggregate and rely on a post-query filter: aggregate SQL cannot use that filter. Ordinary policies preserve the existing count capability. Verification is pending the new focused guard suite.
|
||||
|
||||
|
||||
## Retained upload journal integration (verification in progress)
|
||||
|
||||
Public attachment services always use JdbcUploadJournal for retained originals. Ordinary non-retained streaming behavior is unchanged. Apply UploadJournalTable.h2Script() as an owner migration before enabling this path: the immutable physical manifest includes private storage coordinates and lease tokens which deliberately are not QQQ metadata fields. Generating SQL from metadata alone is insufficient. Only shared file-H2 with actual WRITE_DELAY=0 is currently qualified; memory journals are explicitly injected test fixtures only.
|
||||
|
||||
Retained uploads are capped at20MiB and use conditional single-request creation. The journal commits the candidate, fences a lease, and records UNKNOWN before PUT. A conditional collision or uncertain response is accepted only after reading the bounded original and verifying actual length and SHA-256. Metadata insertion and journal REGISTERED checkpoint share one real owner transaction. The mandatory idempotent afterStored lifecycle callback runs after that commit; COMPLETE follows only after it succeeds. A failed callback leaves the same attachment id recoverable. Source archival refuses transfer while the owner checkpoint is incomplete.
|
||||
|
||||
Renewal occurs at synchronous stage boundaries. A storage call that outlives its lease cannot register under its expired fence; a newer recovery may inspect an existing matching original but never overwrite or delete it. No background task synthesizes or carries a browser session. Provider request deadlines and operational supervision remain host acceptance requirements.
|
||||
|
||||
The native Retained Upload Journal table exposes status to its original actor only, subject to current source visibility; after a completed archive transfer, visibility follows the exact attachment's current parent. Native totals are disabled and private-field predicate/order/unique-key/join probes are denied. The Recover retained upload action reauthorizes the original actor and current attach capability, verifies existing bytes and retries registration. It does not accept bytes, a locator, another actor or a coordinator scope. Coordinated intakeKey entries must retry through their original coordinator, preserving origin/version/provenance. The beforeRecovery(candidate) lifecycle hook is default-deny for retained originals and records a recovery read attempt separately from STORAGE_WRITE_ATTEMPT and user downloads.
|
||||
|
||||
Missing bytes or corrupt bytes remain unresolved, never timeout into cancellation or permission to delete possible evidence. A disabled/deleted original actor has no administrative impersonation escape; an explicitly audited trusted administrative reconciliation path remains to be designed. Existing attachment panels also need discoverable pending-upload recovery navigation; the standard table action alone is not a complete panel UX.
|
||||
|
||||
|
||||
### Recovery navigation metadata
|
||||
|
||||
Retention owners can call public UploadRecoveryNavigation.configure(sourceTable) after producing source metadata. It adds ATTACHMENTS.recoveryView containing the native owner table, label and actor/status filter. The generic Files panel adds literal canonical tableName/recordId AND criteria and navigates to the standard QQQ table and its recovery action. CurrentUser expressions are accepted only for createdByUserId/EQUALS with one value. Filters aid discovery and never replace original-actor/current-parent authorization. Missing or malformed metadata hides this optional navigation on ordinary installations. An interrupted-upload message does not claim saved/completed bytes; recovery still verifies the owner journal and exact object.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Standard attachment list pagination
|
||||
|
||||
The standard `GET /records/{table}/{pk}/attachments` endpoint now applies its
|
||||
page offset and limit inside the native QQQ attachment query. It reads at most
|
||||
`limit + 1` rows (maximum 101), projects the requested page, and uses the extra
|
||||
authorized row only to determine whether to emit the next cursor. It performs no
|
||||
count query and does not load the complete attachment list before slicing it.
|
||||
|
||||
The route, response schema, default limit 20, allowed limits 1–100, and existing
|
||||
version-1 scoped offset cursor bytes are unchanged. Sorting is still descending
|
||||
`createDate`, then descending `id`; equal timestamps therefore retain a stable tie
|
||||
order. These are offset pages, not snapshots: concurrent insertion or deletion can
|
||||
shift offsets. High offsets can require backend scanning; bounded returned rows
|
||||
and host allocation are not a constant-time database performance guarantee.
|
||||
|
||||
Each request still touches the current person, resolves and authorizes the actual
|
||||
source record, canonicalizes its primary key, excludes deleted-parent attachments,
|
||||
and uses the existing per-entry edit/delete policy. The same entry projection is
|
||||
evaluated for the lookahead row. There is no generic post-filter refill or inference
|
||||
from a denied row. Sources transferred to retained archive records use the exact
|
||||
current parent identity and its native authorization policy. This owner artifact
|
||||
does not import Record Files or reconstruct its archive policy.
|
||||
|
||||
Metadata visibility is separate from content release. Listing retained or
|
||||
quarantined metadata does not issue a download, preview or print capability; those
|
||||
paths retain their lifecycle and current authorization checks. The cursor is a
|
||||
routing/offset hint, not a signed authorization token. Current source access is
|
||||
required even when the caller presents a previously valid cursor.
|
||||
|
||||
`AttachmentsService.listPage` is the bounded public service operation. The older
|
||||
unpaged `list` method remains for compatibility; new page consumers should use
|
||||
`listPage`. Cursor parsing/encoding is shared with the existing complete-list
|
||||
pagination helper, so other Collaboration endpoints keep their previous behavior.
|
||||
|
||||
Verification: local focused run `target/attachment-pagination-focused.log` passed
|
||||
40 tests on 2026-10-31 at 02:01:44, with zero failures/errors/skips and Checkstyle.
|
||||
Two new tests execute the actual QQQ RDBMS action against H2 and observe native
|
||||
offset/limit and returned-row bounds, equal timestamp ordering, deleted/other
|
||||
source exclusion, retained flags, terminal pages and current revocation before
|
||||
attachment SQL. The other 38 tests cover existing attachment service behavior and
|
||||
the unchanged OpenAPI fixture. Root HTTP run47446 also passed all12 tests, including
|
||||
the new regression covering three pages,
|
||||
cursor scope, malformed/oversized input and a denied actor. Cross-qbit archive integration, full release verification and installation
|
||||
of this new owner snapshot remain separate gates. No installed or frozen consumer
|
||||
artifact has been replaced by this change.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Attachment cleanup recovery
|
||||
|
||||
Deletion writes a standard `collaboration_attachment_cleanup` row and removes attachment metadata in the same backend transaction. Only after commit does it delete the stored object, then acknowledge the cleanup row. PostgreSQL rejection of metadata deletion leaves both the original row and bytes intact. A provider failure or lost callback leaves the cleanup locator available after restart. The service returns a failure when immediate cleanup fails; the logical attachment deletion may already have committed.
|
||||
|
||||
The cleanup table is visible in the Collaboration app to administrators. Direct table mutations are denied, including to administrators, so callers cannot forge deletion targets. Recovery runs through `collaborationRetryAttachmentCleanup`, guarded for system sessions or `collaboration.admin`. With the host's configured scheduler, it retries a bounded batch of 100 every minute. Kof22 already supplies a scheduler name. Without a scheduler, operators must invoke the recovery process; it is not safe to assume background retries exist.
|
||||
|
||||
Apply the generated PostgreSQL schema before enabling this version; it creates the additional cleanup table and due-time index. Attachment ID is optional because rejected uploads may never have a committed ID. The upgrade preserves existing cleanup rows and removes the earlier attachment-ID uniqueness constraint. Do not remove that table during application rollback while cleanup remains pending. Existing releases do not know how to drain it.
|
||||
|
||||
Verification covers real PostgreSQL metadata rejection, outer upload/delete rollback, commit, provider failure followed by a new recovery service, loss of post-commit callbacks, failed queue acknowledgement and repeat replay. Deterministic storage faults also cover a provider that writes bytes before reporting upload failure and two concurrent cleanup workers. A real MinIO fixture, accessed through the published object-storage QBit, verifies deletion replay in plain and versioned buckets while preserving another object's exact bytes. This is not evidence for every object-storage provider. The separate abrupt-JVM test described below uses fixture file storage.
|
||||
|
||||
Failed upload rollback compensation now records its locator independently of the rolled-back transaction before attempting storage cleanup. Failed worker attempts persist a next-attempt time at least 60 seconds later; this lets healthy requests beyond a failing batch proceed. Tests verify later recovery once the failed requests become due.
|
||||
|
||||
Upload I/O and runtime failures attempt durable compensation and preserve the original exception, adding cleanup failure as a suppressed exception. Deferred provider completion is covered by the settlement protocol below.
|
||||
|
||||
Full Maven verification including operator settlement passes 206 tests, zero failures, errors or skips, plus packaging, help coverage, architecture guardrails and JaCoCo checks. PostgreSQL tests cover the retained receipt, repeated requests, active owners, existing references, rejected receipt writes, provider failure and the real form/Review/Result flow with permission revocation. Coordinated host adoption and staging rollback still require separate verification.
|
||||
|
||||
On backends supporting transactional FOR UPDATE SKIP LOCKED (including the PostgreSQL host), upload now commits a cleanup locator before calling storage and holds its row lock for the upload transaction. Metadata insertion and removal of that locator share the same transaction. Recovery workers skip active uploads and hold the same lock while deleting abandoned bytes and acknowledging cleanup. An existing caller transaction retains ownership until it ends; otherwise the service starts its own transaction after spooling the input. Backends without this locking capability retain callback-based compensation and are not covered by this ownership guarantee.
|
||||
|
||||
Focused PostgreSQL tests verify the locator exists before provider I/O, cleanup skips an active upload, and recovery succeeds after an interrupted upload whose rollback callbacks were deliberately suppressed. That test simulates callback loss. A separate child JVM executes the actual upload service against the disposable PostgreSQL database, persists fixture bytes, then calls Runtime.halt before returning from the provider. The parent verifies exit code 73, absent metadata, retained intent and bytes, and successful idempotent recovery using the original locator. This proves abrupt JVM termination recovery, not power-loss durability or late remote-provider completion. Another test rejects intent insertion in PostgreSQL and verifies zero provider writes or deletes; a completed upload in an open outer transaction remains protected from cleanup until commit. Concurrent workers verify that only one worker enters provider deletion for a locator.
|
||||
|
||||
Issue #1289 remains open for coordinated host adoption and application rollback acceptance. Row locking does not make external object storage and the database one atomic transaction.
|
||||
|
||||
## Unreleased late-upload settlement change
|
||||
|
||||
A PostgreSQL regression reproduced a PUT finishing after timeout rollback had deleted the still-empty location and acknowledged its cleanup row. Upload intents (no attachment ID) now use AttachmentStorage.cleanupUpload, which removes current bytes and reports whether an earlier PUT can still complete. The generic adapter cannot prove settlement and returns false. Recovery retains the locator for later sweeps, with the existing 60-second backoff and 100-row batch. Ordinary committed deletion and successful upload acknowledgement remain unchanged.
|
||||
|
||||
Adapters may report settlement only from authoritative completion/cancellation information, never merely object absence. A deterministic deferred-write fixture verifies the late-completion ordering; this does not establish remote S3 settlement. Generic uncertain-upload records remain pending until the provider proves settlement or an administrator performs the explicit operation below. There is no age-based expiry.
|
||||
|
||||
## Resolve a settled upload
|
||||
|
||||
In the Attachment Cleanup table, select upload intents and run **Resolve settled uploads**. An identified user with `collaboration.admin` must supply provider completion or cancellation evidence covering every selected locator, then confirm the standard Review screen. A support case or request receipt establishing completion is suitable; object absence, an elapsed timeout, or an application process stopping does not establish that an external provider has stopped writing. If settlement cannot be established, leave the intent pending.
|
||||
|
||||
Apply rechecks permission, locks each row, rejects active ownership and existing attachment references, and deletes current bytes before saving completion. Provider or database failures leave the intent unresolved for retry. Committed attachment deletions use the ordinary cleanup retry operation instead.
|
||||
|
||||
The service-managed row retains `resolvedAt`, `resolvedBy` and `settlementEvidence`, together with the original locator. Repeated settlement does not overwrite that receipt; scheduled retries exclude resolved rows. Direct edits and deletes remain denied even to administrators. These fields provide the durable operational receipt without relying on the framework's best-effort audit writer. Store a reference to provider evidence, never credentials or private object contents.
|
||||
|
||||
Apply the additive settlement columns and pending-row index before this code runs. Kof22's corresponding migration is `2026-10-26-collaboration-upload-settlement.sql`. Retain receipts and pending intents during rollback. Do not start an earlier cleanup worker that ignores `resolvedAt`: it could consume retained receipts. Roll back to the pre-cleanup application with cleanup processing disabled, or use a version that understands settlement receipts.
|
||||
@@ -1,76 +0,0 @@
|
||||
# Coordinated collaboration snapshot
|
||||
|
||||
## Combined integration follow-up under qualification
|
||||
|
||||
PR #51 combines current attachment settlement, My Work, presence recovery,
|
||||
contributed-field and capacity follow-ups. Its source inventory and gate history
|
||||
are in [INTEGRATION-20261029.md](INTEGRATION-20261029.md). The candidate selects
|
||||
verified published Redis `0.1.0-redis-integration-20261029-551f1d5-SNAPSHOT` and
|
||||
the existing core `4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT`.
|
||||
The combined collaboration artifact is not yet published. Historical snapshot
|
||||
and acceptance evidence below is not proof of this candidate's deployment.
|
||||
|
||||
## Selectable-field follow-up snapshot
|
||||
|
||||
The following focused upgrade is published for QQQ #1196:
|
||||
|
||||
| Component | Version |
|
||||
| --- | --- |
|
||||
| QQQ BOM and modules | `4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT` |
|
||||
| qbit-collaboration | `0.1.0-field-values-1196-ecff347-SNAPSHOT` |
|
||||
| qbit-redis (unchanged prerequisite) | `0.1.0-redis-7fe5492-SNAPSHOT` |
|
||||
|
||||
Import this QQQ BOM in the consuming host. Collaboration publisher 168 passed
|
||||
from `ecff347efe2bb84949b8452a3383e3fdeb4bcede`; its POM and JAR resolve to
|
||||
timestamp `20261026.133203-1`. Full collaboration verification against the
|
||||
published core passed 198 tests with no failures, errors or skips, plus packaging
|
||||
and coverage gates. An independent empty-cache Java 21 consumer downloaded the
|
||||
pair and compiled both collaboration value providers against the core API.
|
||||
See [the upgrade evidence](VIRTUAL-FIELD-UPGRADE.md) for checksums and scope.
|
||||
|
||||
This is not a combined release of the separate attachment, My Work and presence
|
||||
recovery follow-ups, nor a Kof22 deployment. The earlier candidate's browser
|
||||
acceptance below must not be treated as browser qualification of this new pair.
|
||||
A compatible packaged frontend and host staging qualification remain required.
|
||||
|
||||
## Earlier main-line candidate
|
||||
|
||||
The earlier main-line candidate is published. Its coordinated versions are:
|
||||
|
||||
| Component | Version |
|
||||
| --- | --- |
|
||||
| QQQ BOM and modules | `4.1.0-collab-33464d37e-SNAPSHOT` |
|
||||
| qbit-redis | `0.1.0-redis-7fe5492-SNAPSHOT` |
|
||||
| qbit-collaboration | `0.1.0-collab-8f348da-SNAPSHOT` |
|
||||
| qbit-session-store | `0.1.0-session-b5276ab-SNAPSHOT` |
|
||||
| Next frontend source | `8d372142f8af0c41a75a353edc6d150c33ccf613` |
|
||||
|
||||
Maven snapshot repository: https://central.sonatype.com/repository/maven-snapshots/ . Import the candidate QQQ BOM in the host to align transitive QQQ modules. The standalone consumer was verified from an empty cache. This is a feature snapshot, not a stable/GA release or a production deployment.
|
||||
|
||||
Frontend build and full browser acceptance: https://github.com/QRun-IO/qqq-frontend-next/actions/runs/39683690943 . Its acceptance-build artifact contains the tested export. No dashboard container image publication is implied. Install the export through the normal host frontend packaging path; upgrade frontend and backend together because the shared-presence issued-lease protocol changed.
|
||||
|
||||
## Host requirements
|
||||
|
||||
- Configure a named Redis connection through qbit-redis and enable CollaborationQBitConfig.redisPresence. Use explicit deployment/application namespaces and a PresenceScopeProvider derived from authoritative authenticated tenant context.
|
||||
- Start Redis infrastructure before dependent services and stop dependent services before closing its pools. Session-store may select SHARED_REDIS to reuse the named connection; its session semantics remain owned by session-store.
|
||||
- Configure TLS/authentication and bounded timeouts/pools for the actual environment. The live acceptance fixture's plaintext loopback Redis and mock authentication are test-only.
|
||||
- Deliver SSE without proxy buffering. The stream and its subscription updates must reach the owning web node; configure affinity for that connection. Shared Redis does not make node-local SSE connection IDs portable.
|
||||
- Configure record versions on supported backend paths for stale-save protection. Presence is advisory and never replaces database compare-and-write. Unversioned records do not gain conflict protection merely by enabling presence.
|
||||
- Use explicit QInstance registration when startup code runs outside QContext. Follow core docs/misc/SessionStoreIsolation.md and session-store docs/SESSION-REGISTRATION.md. JDBC shutdown and health operations must target the owning application; see core docs/lifecycle/application-shutdown.md.
|
||||
- Apply generated schema/migrations through the host's migration tooling. Do not infer that publishing this library migrated an existing database or an external deployed host.
|
||||
|
||||
## Verification
|
||||
|
||||
All four standard backend publishers and the three QBit source CI runs passed. Tests against published prerequisites: Redis23, collaboration177, session-store96; zero failures/errors/skips and configured coverage gates passed. Core verification4209 and frontend acceptance39683690943 passed. Frontend local3664 tests and TypeScript passed.
|
||||
|
||||
Independent JAR/POM downloads, exact timestamped versions, SHA-256 hashes and an empty-cache standalone consumer passed. Final browser checks used published backend JARs, the exact CI frontend export, two independent JVMs and one Redis endpoint. Cross-node avatars, self exclusion, concurrent editor field focus, preserved drafts, table presence and departure passed. Redis admin pool/subscription metrics, persisted framework UP health history and health-record navigation passed.
|
||||
|
||||
The two-node fixture uses independent memory records and mock users; it proves published-artifact presence/admin integration. Database conflict and authentication behavior have separate core/frontend acceptance evidence. Publication does not prove unknown deployed hosts have migrated.
|
||||
|
||||
Full evidence and issue reconciliation: https://github.com/QRun-IO/qqq/issues/1293#issuecomment-6329618640 .
|
||||
|
||||
## Supported milestone and optional work
|
||||
|
||||
The owner requested the main-line flow on N web nodes sharing one configured Redis endpoint. Browser delivery remains SSE plus HTTP lease writes; Redis Pub/Sub supplies invalidation hints. A new WebSocket protocol is not required.
|
||||
|
||||
Data-loss/restore qualification (#1300), additional failover topologies (#1301), sustained scale/failure injection (#1302), Windows tooling qualifications (#1304/#1305/#1309), CI mirror resilience (#1307), search layout polish (#1310), long-version preflight (#1312) and test-only AssertJ maintenance (#1314) remain tracked separately in the QQQ project. No measured production capacity or disaster-recovery guarantee is claimed by this snapshot.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Core dependency: after-rollback callbacks
|
||||
|
||||
Collaboration's retained-upload and cleanup paths compensate external object storage only after a transaction's rollback is confirmed. They need two methods on `com.kingsrook.qqq.backend.core.actions.QBackendTransaction`. These are included in QQQ Core `4.1.0-RC.22`, which this module imports through the BOM; Core `4.1.0-RC.21` does not declare them.
|
||||
|
||||
## Required API (reviewed implementation: `b6fb2382`, `third-party-src/qqq-core`)
|
||||
|
||||
`qqq-backend-core`, class `QBackendTransaction`:
|
||||
|
||||
```java
|
||||
public boolean supportsRollbackCallbacks() // default: false
|
||||
public void addAfterRollbackCallback(Runnable callback) // throws UnsupportedOperationException unless supportsRollbackCallbacks(); Objects.requireNonNull(callback)
|
||||
protected void runAfterRollbackCallbacks() // run once, in order, then clear; exceptions (incl. LinkageError) logged and swallowed
|
||||
```
|
||||
|
||||
Pending rollback callbacks are preserved when a required operation marks the transaction rollback-only, and run only after confirmed JDBC rollback. A failed physical commit clears them because its outcome is uncertain. Successful commit discards them.
|
||||
|
||||
`qqq-backend-module-rdbms`, class `RDBMSTransaction`:
|
||||
|
||||
```java
|
||||
@Override public boolean supportsRollbackCallbacks() // true
|
||||
@Override public void addAfterRollbackCallback(Runnable callback) // super.add...; then rolledBackSinceCommit = false
|
||||
// rollback()/close(): after connection.rollback() succeeds -> rolledBackSinceCommit = true; runAfterRollbackCallbacks()
|
||||
// close() rolls back (and so runs callbacks) unless a rollback already happened since the last commit / callback registration
|
||||
```
|
||||
|
||||
## Callers in this module
|
||||
- `attachments/AttachmentsService.storeUpload`: refuses an ambient transaction where `!supportsRollbackCallbacks()`; registers storage compensation.
|
||||
- `attachments/AttachmentCleanupQueue.beginUpload`: registers the same cleanup on both `addAfterRollbackCallback` and `addAfterCommitCallback`.
|
||||
|
||||
## Core tests to carry with the change
|
||||
- Base class: `supportsRollbackCallbacks()` is false; `addAfterRollbackCallback` throws `UnsupportedOperationException`.
|
||||
- RDBMS: callback runs after `rollback()`; does not run after `commit()`; runs when a transaction with a registered callback is closed without commit;
|
||||
a callback registered after an earlier rollback still runs on close; a throwing callback does not stop later callbacks.
|
||||
- Integration (already in this module, needs the core): `attachments/AttachmentTransactionsPostgresTest` (needs Docker) incl. `interruptedUploadSurvivesLostRollbackCallback`.
|
||||
@@ -1,51 +0,0 @@
|
||||
# Coordinated collaboration integration candidate
|
||||
|
||||
This branch combines the current heads of the attachment, My Work, presence
|
||||
recovery, virtual-field and capacity PRs. It is a qualification candidate, not
|
||||
a published release or a deployed host.
|
||||
|
||||
| PR | Included source |
|
||||
| --- | --- |
|
||||
| #45 attachment settlement | `dc65a826ce362416d55d91136fa39225f4a7cf94` |
|
||||
| #46 bounded My Work | `cdcf7ee36509c7a1fc6f22fa864209eb80a6ff34` |
|
||||
| #47 presence recovery | `776ebe3fcb6492881bc9d7ec3f6ca6adfa320200` |
|
||||
| #48 contributed field values | `1da538cf43ba1ed80e3f8fd599729b5691563cbf` |
|
||||
| #49 capacity qualification | `56a8b760d9c1a1862cb11e80fad07cf5cdc20b3a` |
|
||||
|
||||
All five heads are ancestors of integration commit
|
||||
`44a89d77b1ad3a39d26c512ada78af0884872013`. The stacked My Work and capacity
|
||||
branches did not contain the latest heads of their base PRs, so those base
|
||||
heads were merged explicitly as well. No production source was rewritten during
|
||||
integration. The core prerequisite remains
|
||||
`4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT`. The candidate now selects
|
||||
published Redis `0.1.0-redis-integration-20261029-551f1d5-SNAPSHOT` after native
|
||||
publisher job42 succeeded and independent timestamped POM/JAR downloads matched
|
||||
their declared version and repository checksums. The timestamp is
|
||||
`20261029.165102-1`; JAR SHA256 is
|
||||
`385f69c5d84362dbab40a3044261fb9820e25126785d7f22901121302e6ce076`.
|
||||
|
||||
The caller and manifest both select released `kingsrook/qqq-orb@0.8`. The
|
||||
canonical validator extracted from released 0.8.32 and CircleCI schema validation
|
||||
pass; no unpublished orb changes are used.
|
||||
|
||||
Local `./mvnw.cmd verify -Dqqq.mywork.load=true` ran 238 tests with no skips, but
|
||||
reported three failures and four errors. Failures include Redis command read
|
||||
timeouts/rejoin assertions and PostgreSQL connection I/O during the attachment
|
||||
fixture. Docker Desktop's published-port path has independently exhibited
|
||||
300–900 ms replies after idle, compared with 0–58 ms on a direct Docker network.
|
||||
This observation is not proof of every failure's cause. No timeout, assertion,
|
||||
security boundary, or quality gate was weakened. Native hosted Linux results
|
||||
are required before publication; the local run is **not** a passing gate.
|
||||
|
||||
Native CircleCI job192 subsequently passed the full verification at integration
|
||||
head `02085baff928c374e3b0d77367fbcc8182f81f93`; CodeQL also passed. Those checks
|
||||
used the earlier Redis pin. The new published Redis pin above requires its own
|
||||
current-head native verification before this collaboration artifact is published.
|
||||
Use an explicit short feature branch (`feature/collab-int-20261029`) and dry-run
|
||||
the released version calculator: generated help-bundle versions must remain at
|
||||
most 50 characters. The longer `collaboration-integration-20261029` name would
|
||||
produce 57 characters and is not a valid publication route.
|
||||
|
||||
The successful earlier combined-source checks remain historical evidence only.
|
||||
Host TLS/ACL, actual identity/proxy/provider acceptance, maintenance/restore
|
||||
enforcement, artifact publication, and deployment are separate gates.
|
||||
@@ -1,92 +0,0 @@
|
||||
# My Work summary query bounds
|
||||
|
||||
The summary previously materialized every matching task, assignment and mention.
|
||||
A regression fixture measured 601 parent lookups for 601 tasks on two parents.
|
||||
|
||||
The summary now scans each category in primary-key pages of at most 200 rows.
|
||||
It retains at most ten display tasks and sorts them using the existing due-date
|
||||
order (earliest first, null last, then ID). Counts still include every visible
|
||||
matching row, rather than silently truncating at a display limit. Assignment and
|
||||
mention filters remain scoped to the signed-in user.
|
||||
|
||||
Each page caches parent visibility only for that page, using the existing caller
|
||||
permission and record-lock checks. Missing or revoked parents contribute neither
|
||||
rows nor counts; unexpected authorization/backend failures propagate. No decision
|
||||
is shared across requests. The starting maximum ID bounds each category scan so
|
||||
concurrent inserts cannot extend it indefinitely. Like the earlier summary, this
|
||||
is not a transactionally consistent snapshot across the three categories.
|
||||
|
||||
This bounds row retention, not total work independently of data size. For N
|
||||
matching rows, each category needs one high-watermark query plus approximately
|
||||
ceil(N/200) page queries, and one parent lookup per distinct parent per page.
|
||||
Many unique parents still require linear authorization work. Per-record heavy
|
||||
field size is also outside this row-count bound. Database and browser latency,
|
||||
N-node presence, saturation and production capacity remain part of #1291/#1302.
|
||||
|
||||
Regression evidence: the 601-task case passes a budget of at most eight parent
|
||||
queries, with no task query above 200 rows; correct top-ten ordering and revoked
|
||||
access on a subsequent request are checked. Another case crosses assignment and
|
||||
mention page boundaries and excludes hidden parents, other users and undelivered
|
||||
mentions. These tests use the framework's real memory backend instrumentation.
|
||||
|
||||
`MyWorkPostgresTest` repeats the 601-row case using PostgreSQL 17 and the generated
|
||||
production schema. Independent `pg_stat_statements` observations verify exactly
|
||||
five task queries and between one and eight parent queries. It also verifies the
|
||||
same top ten, visible count and next-request permission revocation against SQL.
|
||||
The fixture passes; these query counts are not sustained throughput or
|
||||
deployment-capacity measurements.
|
||||
|
||||
## Reproducible local workload
|
||||
|
||||
Run `mvn -Dtest=MyWorkPostgresTest -Dqqq.mywork.load=true test` with Java21 and
|
||||
Docker. The fixture owns a disposable PostgreSQL17 container, uses the generated
|
||||
schema and real authorization checks, and writes `target/mywork-load.csv`.
|
||||
It never connects to an application database. The optional workload runs after
|
||||
the normal query-budget regression so ordinary CI keeps the smaller test.
|
||||
|
||||
Each scenario has 2,000 tasks: one uses ten shared parents, the other 2,000 distinct
|
||||
parents. Every tenth parent is inaccessible. Two warmups precede ten measured
|
||||
sequential summaries. Every sample verifies 1,800 visible tasks and the exact top
|
||||
ten. SQL calls and server execution time are collected independently using
|
||||
pg_stat_statements after warmup. Latency includes service execution and database
|
||||
round trips, excluding fixture setup and assertions.
|
||||
|
||||
CSV percentiles use nearest rank. With only ten samples, p95 and p99 both equal
|
||||
the maximum observed sample; they are descriptive, not stable tail estimates.
|
||||
SQL figures are totals for all ten samples. This is a single-JVM local workload,
|
||||
not HTTP/browser latency, sustained concurrency, pool saturation, multi-node
|
||||
presence or a deployment-capacity claim. Do not derive production alert thresholds
|
||||
from these numbers. No wall-clock performance assertion is added to CI.
|
||||
|
||||
The fixture explicitly uses the framework C3P0 connection provider, matching the
|
||||
host's provider, with initial/minimum1, maximum3 and a two-second checkout timeout.
|
||||
It verifies the pool stays within that cap, has no checkout timeouts and returns
|
||||
all borrowed connections after each scenario. The original fixture used the
|
||||
unpooled default; the expanded workload failed with a Windows socket-allocation
|
||||
error after repeatedly opening connections. This was a fixture mismatch, not
|
||||
proof of a Kof22 outage. The same workload completed with pooling.
|
||||
|
||||
Observed local run (Java21, PostgreSQL17 in Docker on Windows, ten sequential
|
||||
samples after two warmups): shared-parent p50=91.192ms, max=112.105ms; distinct-parent
|
||||
p50=960.565ms, max=1100.260ms. SQL calls per summary were113 and2013 respectively.
|
||||
This confirms linear distinct-parent authorization work remains despite bounded
|
||||
row retention. The existing exact-count semantics still require examining every
|
||||
matching task. No runtime optimization or production-capacity claim follows from
|
||||
this fixture measurement alone.
|
||||
|
||||
## Compatibility with the later attachment changes
|
||||
|
||||
On October 26, an isolated source export combined attachment commit
|
||||
`dc65a826ce362416d55d91136fa39225f4a7cf94` with the four My Work files from
|
||||
`2ac067bea4af199486f90e2dc5212baf8910b8fb`. Both branches share base
|
||||
`15cb8cbf72aa9a5e033280cf5a38384d1295fc7d`; their changed files do not overlap.
|
||||
This was a source-overlay verification, not a branch merge or publication.
|
||||
|
||||
Java 21 `./mvnw.cmd verify -Dqqq.mywork.load=true` passed all 209 tests with zero
|
||||
failures, errors or skips, including the 29 attachment transaction tests.
|
||||
Packaging and JaCoCo gates passed. The optional PostgreSQL workload retained the
|
||||
same visible-count, top-ten, query-budget and pool assertions. Shared-parent
|
||||
p50/max were 76.301/96.044 ms; distinct-parent p50/max were 939.896/1035.402 ms.
|
||||
SQL counts remained 113 and 2013 per summary. These local measurements preserve
|
||||
the limitations above; they do not qualify an integrated published artifact or
|
||||
Kof22 deployment. The eventual integrated branch still needs its own CI gates.
|
||||
@@ -1,179 +0,0 @@
|
||||
# Presence recovery candidate — QQQ #1300
|
||||
|
||||
This is an unreleased implementation candidate in collaboration draft PR47.
|
||||
The existing store/service suites and CI pass; host adoption and enforced
|
||||
maintenance remain incomplete. It is not part of the published snapshots.
|
||||
|
||||
## Contract under qualification
|
||||
|
||||
Each deployment/application/tenant/table has a persistent recovery guard in the
|
||||
same Redis hash slot as its bounded room projections. Each Lua operation reads
|
||||
that guard atomically with its room work. The guard contains a generation, a
|
||||
readiness deadline and the latest issued expiry. A missing guard, or complete
|
||||
loss of table projections before that expiry, starts a fresh generation and a
|
||||
recovery interval of one lease lifetime. Existing live browsers can reserve and
|
||||
renew replacement leases during that interval. Snapshots report recovery rather
|
||||
than a successful empty room until the interval finishes.
|
||||
|
||||
Lease entries carry the guard generation. Recovery removes invalid-generation
|
||||
projections from touched rooms so they cannot consume all capacity and prevent
|
||||
re-registration. Existing authorization remains in PresenceService; the guard
|
||||
does not grant record, tenant or field access. The shared adapter maps recovery
|
||||
to a sanitized service-unavailable response. Record-version conflict checks are
|
||||
independent of this advisory state.
|
||||
|
||||
An untouched new namespace is conservatively unknown too. Its first snapshot
|
||||
therefore starts a recovery interval. Normal idle expiry retains the guard so
|
||||
later empty snapshots can be known-empty. The guard has no TTL; tenant-retirement
|
||||
operations and long-term guard storage must be included in operational capacity
|
||||
qualification.
|
||||
|
||||
## Controlled restore
|
||||
|
||||
The host configuration has a bounded `recoveryGeneration`, default `initial`,
|
||||
included in both the storage namespace and realtime audience. It belongs in
|
||||
deployment configuration outside the Redis backup. All nodes must use the same
|
||||
value. A restored guard can otherwise restore old authority; Redis process IDs
|
||||
cannot detect arbitrary same-process data restoration.
|
||||
|
||||
The required maintenance procedure is: stop admission and drain all old web
|
||||
nodes; restore Redis; choose a never-reused generation outside Redis; start only
|
||||
nodes with that generation; verify recovery and authenticated re-registration;
|
||||
then reopen traffic. Do not roll back the generation when rolling back an app.
|
||||
The host tooling that enforces and verifies this sequence is **not implemented**
|
||||
yet. The branch does not claim transparent arbitrary restore or split-brain
|
||||
recovery. Native Sentinel/Cluster routing remains the separate Redis issue #1301.
|
||||
|
||||
The storage prefix is v2 so old executables cannot treat generation-bound entries
|
||||
as old-format authority. This requires a coordinated deployment, not a mixed
|
||||
version rolling deployment. Existing published v1 snapshots are unchanged.
|
||||
|
||||
## Evidence and remaining work
|
||||
|
||||
- Before the patch, a two-client disposable Redis test failed because total loss
|
||||
returned an empty snapshot.
|
||||
- Seven focused tests now pass: full loss, loss of volatile projections while the
|
||||
guard survives, restored guard/lease bytes followed by a host generation
|
||||
change, bounded generation validation, and rejection of inconsistent lease
|
||||
lifetimes across nodes, guard loss across previously unvisited records, and
|
||||
shared cold readiness followed by normal idle expiry. The latter verifies
|
||||
that peer reads do not extend the deadline, the guard remains persistent,
|
||||
expired projections remain known-empty and expired leases cannot renew.
|
||||
These use independent clients in
|
||||
one JVM, not a deployed HA cluster.
|
||||
- Full wrapper verification passed on October 26: 184 tests, no failures,
|
||||
errors or skips; packaging and coverage gates passed. This includes the
|
||||
original seven store tests and two-node six-viewer/two-editor scenario.
|
||||
The admin dashboard reads every table, so the fixture initializes every
|
||||
dashboard table for both tenants through the real backend and waits for
|
||||
recovery without writing Redis guard state.
|
||||
- The service fixture socket read timeout is one second; its previous 200 ms
|
||||
limit caused a Redis EVALSHA read timeout. Production timeouts are unchanged.
|
||||
- The full run includes a real HTTP recovery test: real Redis
|
||||
FLUSHDB produces 503 during recovery, the former lease returns 409 with
|
||||
PRESENCE_LEASE_EXPIRED, hidden records and unauthorized viewers remain
|
||||
inaccessible, and a new lease rejoins through the production recovery window.
|
||||
This uses a loopback HTTP server with fixture authentication, not a browser
|
||||
or a deployed host. The focused test and the combined full suite both pass.
|
||||
- The store suite checks loss of either state or expiry data in both record
|
||||
and table projections. Server-issued lease renewal must fail unavailable
|
||||
without changing the surviving projection. It also covers complete loss
|
||||
of either projection pair and stale departure ordering.
|
||||
- Still required: deployed-host qualification and enforced host maintenance/reset. Local
|
||||
per-operation cost measurements at 1,000 and 10,000 leases are recorded in
|
||||
`acceptance/RECOVERY-COST.md`; sustained production capacity is unqualified.
|
||||
The implementation uses no INFO call or lifecycle polling cache.
|
||||
- This is an unreleased draft candidate. Publication and production deployment
|
||||
have not occurred; the remaining acceptance work above is still required.
|
||||
|
||||
## Independent-process acceptance
|
||||
|
||||
`docs/acceptance/presence_recovery_processes.py` passed on October 26 using
|
||||
two independent `SharedPresenceDevServer` JVMs and one disposable Redis container.
|
||||
Each user renews through the opposite node. After FLUSHDB, both nodes return 503
|
||||
for presence and 409 for the former leases; fresh leases restore both viewers on
|
||||
both nodes. The harness terminates only its owned processes and container.
|
||||
This is fixture authentication with independently seeded memory records, not a
|
||||
deployed HA application or a browser acceptance result.
|
||||
|
||||
Reproduce from the repository root after `./mvnw test-compile`:
|
||||
|
||||
```text
|
||||
./mvnw dependency:build-classpath -Dmdep.includeScope=test -Dmdep.outputFile=target/recovery-process-classpath.txt
|
||||
python docs/acceptance/presence_recovery_processes.py --java /path/to/java --repo /path/to/repository
|
||||
```
|
||||
|
||||
Requires Python 3, Java 21 and Docker. JVM output and argument files stay under
|
||||
`target/recovery-processes-*` for diagnosis. The harness uses the production
|
||||
45-second recovery window, with bounded startup and recovery deadlines.
|
||||
|
||||
## Browser recovery acceptance
|
||||
|
||||
The same harness accepts `--frontend-repo /path/to/qqq-frontend-next`. It copies
|
||||
that checkout's existing `out` export into the owned test output directory and
|
||||
serves it through the real middleware on both JVMs. Playwright is loaded from
|
||||
the frontend checkout; no API mocks are installed. Both browser users first
|
||||
see the other viewer, then Redis FLUSHDB triggers the unavailable message and
|
||||
removes stale people. Both viewer badges return without reloading either page.
|
||||
|
||||
The final fixed-candidate run passed all five profiles on October 26 against
|
||||
commit `4b0d6a32d6bd8ecba023857d3661d9f3f5980975`: Chromium, Firefox, WebKit,
|
||||
Pixel 7 emulation and iPad emulation. Source/compiled Lua hashes were checked
|
||||
before the run and after each profile. Traces and screenshots remain under
|
||||
each `target/recovery-processes-*` directory. The earlier Chromium screenshot
|
||||
in `target/recovery-processes-229f305e83` was also visually inspected.
|
||||
|
||||
Select profiles using `--browser-profile chromium|firefox|webkit|mobile|tablet`.
|
||||
These tests use the local export and do not establish provenance of a newly
|
||||
published frontend artifact, physical-device behavior or deployed Kof22
|
||||
acceptance. Fixture frontend serving is opt-in via `collab.dev.frontend`;
|
||||
its default remains false.
|
||||
|
||||
## Controlled restore acceptance
|
||||
|
||||
`acceptance/presence_restore_processes.py` exercises a disposable Redis RDB
|
||||
restore with two independent JVMs. It saves the populated dataset, verifies
|
||||
both old JVMs have terminated and their listeners have closed, clears the
|
||||
dataset, reloads the saved image and starts both nodes with a shared fresh
|
||||
generation supplied outside Redis. It checks recovery responses, rejects old
|
||||
leases on both nodes, and requires two fresh viewers to rejoin across nodes.
|
||||
The final run passed on October 26; logs are retained in
|
||||
`target/restore-processes-aec5288289`. Both leases are explicitly renewed before
|
||||
saving, and rejection must complete within 40 seconds of that renewal, before
|
||||
their normal 45-second expiry. Old leases are rejected again after fresh
|
||||
viewers have rejoined.
|
||||
|
||||
The fixture enables Redis DEBUG RELOAD only on its owned loopback container.
|
||||
Run after test compilation and classpath preparation:
|
||||
|
||||
```text
|
||||
python docs/acceptance/presence_restore_processes.py --java /path/to/java --repo /path/to/repository
|
||||
```
|
||||
|
||||
This is a protocol acceptance fixture, not host maintenance automation. Actual
|
||||
host admission closure, replacement prevention and generation configuration
|
||||
still require implementation and staging validation.
|
||||
|
||||
To qualify a packaged backend, add `--backend-jar /path/to/qbit-collaboration.jar`.
|
||||
This excludes production `target/classes` from the JVM classpath, rejects a
|
||||
duplicate collaboration dependency JAR, and records the artifact SHA-256 in
|
||||
`target/restore-processes-*/result.json`. The receipt includes the elapsed time
|
||||
for rejection of old leases (required to remain below their normal expiry),
|
||||
the restore/rejoin checks and fixture cleanup. Cleanup failure fails the run.
|
||||
The test repository still supplies compiled fixture classes and its dependency
|
||||
classpath; this is not a standalone production launcher.
|
||||
|
||||
## Packaged browser qualification
|
||||
|
||||
The process harness accepts `--backend-jar` and `--frontend-jar` for artifact
|
||||
verification. With a backend JAR, production `target/classes` is omitted from the
|
||||
runtime classpath; test classes provide only the disposable server fixture. The
|
||||
frontend JAR is served directly instead of copying a local `out` export.
|
||||
`--frontend-repo` remains required for Playwright tooling. Dependency classpaths
|
||||
must not contain a second collaboration or frontend artifact.
|
||||
|
||||
Each run writes `result.json` with the selected artifact SHA-256 hashes, browser
|
||||
profile, result and cleanup outcome. A successful test with failed fixture cleanup
|
||||
exits unsuccessfully. This records local artifact qualification, not publication
|
||||
or deployment provenance. Supply `--browser-profile` to qualify each required
|
||||
browser independently.
|
||||
@@ -1,6 +0,0 @@
|
||||
# Known issues
|
||||
|
||||
- Deployment-specific object storage, database failover, backup/restore and retention policies need host qualification.
|
||||
- Docker must be running for the attachment transaction tests; enable both `mysqlJournalTests` and `postgresJournalTests` for the additional journal-provider cases. On Windows use `-Dexec.skip=true` for the inherited POSIX console-summary command.
|
||||
- Redis presence needs `qbit-redis` on the classpath; it is an optional dependency.
|
||||
- The matching Next UI and host configuration must be verified before rollout.
|
||||
@@ -1,13 +0,0 @@
|
||||
# Collaboration — status
|
||||
|
||||
**Version 1.0**, built and published by the shared `ci-workflows` Maven pipeline to the KOF22 Gitea Maven registry (`com.kingsrook.qbits`); `mvn deploy` runs the tests once.
|
||||
|
||||
## What it contains
|
||||
|
||||
Attachment lifecycle with the retained upload journal, recovery and cleanup APIs; presence including Redis-backed presence; tags, custom fields, comments with mentions, sharing, favorites, follow, timeline and tasks; virtual-field and fixture support.
|
||||
|
||||
## Dependencies
|
||||
|
||||
QQQ Core `4.1.0-RC.22` (imported through its BOM; it provides the rollback callbacks the upload paths need). `qbit-redis` is optional and only needed for Redis presence. Build with Java 21 and `./mvnw verify`; Docker must be running for the attachment transaction tests (set `mysqlJournalTests` and `postgresJournalTests` for the extra journal-provider cases).
|
||||
|
||||
Host qualification of object storage, database failover, backup/restore and retention policy remains the deploying team's responsibility. See [known issues](QBIT-KNOWN-ISSUES.md).
|
||||
@@ -1,47 +0,0 @@
|
||||
# Shared Redis presence candidate
|
||||
|
||||
Status: coordinated candidate artifacts are published; remote CI, empty-cache consumer resolution and published-artifact two-node browser checks passed. See [CANDIDATE-RELEASE.md](CANDIDATE-RELEASE.md) for exact versions and deployment requirements. This guide describes the current main-line scope. See MAINLINE-ACCEPTANCE.md for evidence and SESSION-STATE.md for live release checkpoints.
|
||||
|
||||
## Host configuration and dependency ownership
|
||||
|
||||
Multiple QQQ web nodes share one configured Redis endpoint. The host enables CollaborationQBitConfig.redisPresence, supplies a started named Redis connection, explicit deployment/application names, and a PresenceScopeProvider deriving one tenant scope from authenticated host context. Browser input cannot choose the tenant.
|
||||
|
||||
Collaboration owns presence semantics, Lua scripts, record/table projections and authorization. qbit-redis owns connections, namespaces, script execution, pool lifecycle, admin management and framework health integration. Collaboration production code consumes only the Redis public API and does not import Jedis. Without shared configuration, the local presence backend remains available for a single node; its issued authority is not distributed. Runtime Redis failure never silently switches an HA host to local presence.
|
||||
|
||||
Browser delivery uses the existing SSE stream with HTTP lease writes. Redis Pub/Sub carries scoped invalidation hints between nodes; a new WebSocket transport is not required. Hints contain table/record identifiers, not record contents or viewer lists. The recipient rereads through the authorized presence API. Core checks the authenticated recipient audience during live delivery and replay. Coordinate core/frontend/QBit versions and do not share a topic with audience-unaware hubs.
|
||||
|
||||
## Issued lease lifecycle
|
||||
|
||||
The candidate collaboration extension version is 1.3.0:
|
||||
|
||||
- POST /presence/leases authorizes the table/record and creates an invisible, expiring reservation, returning a server-issued connectionId.
|
||||
- POST /presence/leases/renew requires that issued handle and a positive safe-integer sequence. It activates or updates existing live authority; it cannot recreate an expired or departed lease.
|
||||
- DELETE /presence performs ordered departure. Delayed renewals cannot reopen the departed issued handle.
|
||||
- HTTP 409 with code PRESENCE_LEASE_EXPIRED tells an active browser to open a fresh lease. Input, permission and infrastructure failures retain their standard mappings. Unavailable shared storage returns 503 rather than an empty-room result.
|
||||
- Shared presence rejects legacy heartbeat upserts. Local legacy behavior remains for compatibility.
|
||||
|
||||
The Next candidate awaits open before activation, uses the handle across focus changes, and releases it on hide/unmount. It opens again on pageshow or explicit expiry. Generation checks discard old asynchronous responses; a late open result is released without activation. One immediate rejoin is allowed after expiry, then retries follow the normal 20-second heartbeat. Infrastructure errors do not churn handles. The UI preserves drafts and shows unavailable status when presence cannot be read or renewed.
|
||||
|
||||
## State and permissions
|
||||
|
||||
Record and table rooms use bounded expiry indexes and state hashes in the same Redis cluster hash slot for their deployment/application/tenant/feature namespace. Lua updates both projections atomically using Redis TIME. User/connection/record identifiers are encoded independently. No record values are stored.
|
||||
|
||||
Snapshots merge tabs by user. EDITING takes precedence over VIEWING; focus follows the latest renewal within the selected mode. Table projections omit focused fields. Expired entries are pruned on reads/mutations and idle keys expire. Invisible reservations count toward capacity. Scripts use EVALSHA with fallback only for NOSCRIPT, through the Redis QBit API.
|
||||
|
||||
Reads and renewals recheck parent and field access. Canonical record identifiers and recipient field filtering are enforced. Admin counts are limited to known rooms in the administrator's authenticated tenant; non-admins receive no counts. Capacity and TTL limits bound individual operations; they are not measured sustained production throughput guarantees.
|
||||
|
||||
## Verified behavior
|
||||
|
||||
- Real Redis tests cover six users on a record with two editors, duplicate-tab aggregation, ordering, departure, expiry, isolation, capacity and script-cache recovery.
|
||||
- Service and real HTTP tests cover issued open/activation/renew/departure, 409 expiry, authorization, canonical keys, field projection and unavailable responses.
|
||||
- Clean release-source collaboration verify passed 177 tests with coverage/architecture gates; focused frontend suites passed 34 tests, TypeScript and changed-file lint.
|
||||
- Live browser acceptance with mocks disabled used three identities on two independent QQQ JVMs sharing Redis. It verified avatars, field focus, drafts, table projection and departure. Demo records were independently seeded memory data; this is presence evidence, not cross-node database save-consistency evidence.
|
||||
- Live Redis admin acceptance verified pool/subscription status, persisted framework health results and navigation to standard health records.
|
||||
|
||||
Presence is advisory. Atomic optimistic record-version checks protect saves independently. Coordinated cold-consumer acceptance against published artifacts remains required; local Maven cache success does not prove remote artifact availability.
|
||||
|
||||
## Deferred work
|
||||
|
||||
The user narrowed this release to the normal flow. Optional qualification is tracked in QQQ issues #1300 (data loss/backup restore), #1301 (failover/disaster topologies), and #1302 (sustained scale/failure injection), all on the QQQ Roadmap backlog.
|
||||
|
||||
Issued leases do not provide disaster-recovery fencing: complete state loss can temporarily look empty, and restoring an old live lease can restore its authority. No recovery generation guard is implemented in this candidate. The deferred investigation is tracked in [QQQ #1300](https://github.com/QRun-IO/qqq/issues/1300).
|
||||
@@ -1,52 +0,0 @@
|
||||
# Retained upload journal
|
||||
|
||||
The Collaboration-owned journal is wired into retained `AttachmentsService` upload and recovery. It persists candidate metadata before object creation, owns the attachment/checkpoint transaction, and exposes native read-only recovery navigation. It has no Record Files dependency and stores no second copy of file bytes.
|
||||
|
||||
The integrated owner snapshot passed all **214 tests**, including 10 real MySQL provider cases and 11 H2 journal regressions, and was installed under `0.1.0-record-files-SNAPSHOT`. The subsequent doublewrite/session-read-only delta compiles and awaits its provider rerun. This document does not qualify Aurora, other SQL vendors, external object storage, disks, backups, or a host deployment.
|
||||
|
||||
## Ownership and recovery
|
||||
|
||||
1. `prepare` independently commits the immutable server-built candidate before the first PUT. Matching retries reuse it; changed manifests conflict.
|
||||
2. `reserve` locks the row and grants a bounded lease with a monotonically increasing fence. `renew` extends only a still-live exact reservation at synchronous stage boundaries.
|
||||
3. `recordStorage(UNKNOWN)` precedes conditional object creation. `VERIFIED` means the storage owner proved the exact length and SHA-256.
|
||||
4. `register` passes its real transaction to the attachment registrar. The attachment insert and REGISTERED checkpoint commit together; callback failure rolls both back.
|
||||
5. The retained lifecycle registration runs after commit and is retried idempotently. The journal becomes COMPLETE only after it succeeds. Completed coordinated retries still invoke required lifecycle registration without adding a second attachment or activity event.
|
||||
6. Recovery reauthorizes the original actor and current source. It verifies the stored original without overwriting it and resumes registration. Missing or unreadable bytes remain uncertain; timeout never proves absence.
|
||||
|
||||
The owner rejects ambient/no-op transactions. SQL values are bound; mutable operations lock rows, check the token/fence/expiry, and require exactly one guarded update. Reads reconstruct manifest hashes. Private locator and lease columns are absent from QQQ metadata and forbidden in native filters/projections; native direct writes and unscoped totals are denied. Current actor/source authorization remains in the owner service and native journal security, not in the low-level JDBC port.
|
||||
|
||||
Disabled or deleted original actors still need an explicitly designed administrative reconciliation flow. No fabricated session or broad archive permission substitutes for that flow. Broad post-filtered listings can contain short pages; source-pinned native recovery navigation is the supported user entry point.
|
||||
|
||||
## Database boundaries
|
||||
|
||||
Both owner tables must use the same QQQ backend. Hosts apply DDL through their migrations; runtime code never creates tables or changes server-global settings.
|
||||
|
||||
| Backend | Runtime requirements | Schema |
|
||||
| --- | --- | --- |
|
||||
| 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()` |
|
||||
| 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.
|
||||
|
||||
Each MySQL operation checks `innodb_flush_log_at_trx_commit=1`, and `sync_binlog=1` when binary logging is enabled; a read-only server or session is refused. It also requires `innodb_doublewrite` to be `ON` or `DETECT_AND_RECOVER`; [detect-only mode does not retain recoverable page copies](https://dev.mysql.com/doc/refman/8.4/en/innodb-doublewrite-buffer.html). These settings follow the [MySQL 8.4 InnoDB durability documentation](https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit) and [binary-log durability documentation](https://dev.mysql.com/doc/refman/8.4/en/replication-options-binary-log.html#sysvar_sync_binlog). Hardware that misreports flush completion can still lose data; replication/failover, backups, restore and disk guarantees remain deployment qualifications. Runtime settings are checked at transaction entry; an administrator changing server settings concurrently is outside the application trust boundary.
|
||||
|
||||
The MySQL journal uses millisecond timestamps with explicit UTC JDBC calendars, InnoDB, enforced CHECK constraints and binary NO PAD string comparisons. Its live session must report UTC (+00:00/UTC, or SYSTEM with a UTC system zone); the journal does not silently change a pooled session timezone. Hosts should configure the native JDBC driver timezone consistently for generic QQQ date fields as well. The host must configure each journal connection with `innodb_lock_wait_timeout` between 1 and 5 seconds, through its backend JDBC sessionVariables/pool setup (or a dedicated database default). Qualification reads and preserves that session setting; it never changes it or other pooled-session/global settings. Each statement also has a ten-second query timeout. Duplicate error 1062 is distinguished from unrelated integrity failures; deadlock/lock-timeout failures remain typed BUSY responses, never fabricated success.
|
||||
|
||||
## Reproducible tests
|
||||
|
||||
Use the repository Maven wrapper and the same revision/QQQ dependency overrides as the integrated build. The default suite remains Docker-independent; explicitly enable the provider fixture:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd '-Drevision=0.1.0-record-files-SNAPSHOT' '-DmysqlJournalTests=true' '-Dtest=MySqlUploadJournalTest,UploadJournalTest' test
|
||||
```
|
||||
|
||||
`MySqlUploadJournalTest` creates only its own Testcontainers MySQL 8.4.13 container, random synthetic credentials and private anonymous data volume, and destroys that container on completion. It does not use existing host services. The image is pinned to `mysql@sha256:a0225d1ea6631c8bc2385dc4c2faba17bbf1834ecf369e1659a9a382bd8d5dd7`. Credentials are not printed by the fixture.
|
||||
|
||||
The eleven provider cases cover acknowledged prepare surviving a real database process restart, attachment/checkpoint rollback and completion replay, simultaneous prepare/lease fencing, wrong backend/catalog/mapping/ambient/Aurora refusal, actual non-durable server settings and MyISAM refusal, binary identifier comparisons and native write denial, actual lock-timeout/BUSY behavior, column/index drift and immutable conflict, and lease instants across differing JVM default zones, preservation/refusal of host session lock-timeout settings, and refusal of detect-only doublewrite/read-only sessions without changing their settings. The attachment schema in this suite is deliberately a minimal transaction fixture; this is not an end-to-end MySQL object-storage or tenant/archive qualification. Record Files owns its separate provider integration tests.
|
||||
|
||||
First provider run compiled but failed its restart fixture because Docker reassigned the dynamic port and Testcontainers retained a cached mapping; two subsequent setup errors followed that stale endpoint. The fixture now refreshes the inspected binding after restart and uses bounded JDBC readiness checks. Docker wire logs are suppressed before container creation. Expanded snapshot verification: **20 tests passed (9 MySQL, 11 H2), zero failures/errors/skips**, `target/mysql-journal-focused-verification.log`, completed 2026-10-31 00:01:44. The tenth provider case and full 214-test owner quality gates subsequently passed; the corrected development-GAV artifact SHA-256 is `72D6E36E77B92BE3ECFE9BE7042D292C76B308AA05A9E43BD490B229BD1A75E2`. The eleventh provider regression and doublewrite/read-only delta await fresh verification.
|
||||
@@ -1,76 +0,0 @@
|
||||
# Selectable collaboration fields: published feature snapshot
|
||||
|
||||
Tracks [QQQ #1196](https://github.com/QRun-IO/qqq/issues/1196).
|
||||
|
||||
This source branch requires the new core virtual-field value-provider API. Its
|
||||
default BOM pin is now the published
|
||||
`4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT`. Core publisher job 9716
|
||||
passed from commit `cc3981bb3c95e8f2dd7ef2a02e2233db7d0e2a79`; an independent
|
||||
consumer with an initially empty Maven repository downloaded its BOM and core
|
||||
and compiled the provider interface on October 26, 2026. Both artifacts resolve
|
||||
to timestamp `20261026.130631-1` from Sonatype's snapshot repository.
|
||||
|
||||
Published artifact SHA-256 receipts:
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| --- | --- |
|
||||
| BOM POM | `d68cf837d7e07513c4e70977829f99e6236cb3e89f8af85fc75239d5b52564a5` |
|
||||
| Core POM | `d89d491aaa9312051ec462e1cf165b948ee3e88c9aec2721ff25a00e956c431b` |
|
||||
| Core JAR | `ae6dbeba8d0e4bc55f2714303ee3482dbf6a0cd34c03663c6009d8fe2c1da54d` |
|
||||
|
||||
The collaboration follow-up is published as
|
||||
`0.1.0-field-values-1196-ecff347-SNAPSHOT`, timestamp
|
||||
`20261026.133203-1`. Publisher job 168 passed from commit
|
||||
`ecff347efe2bb84949b8452a3383e3fdeb4bcede`, using signed tag
|
||||
`publish-values1196-20261026-ecff347`.
|
||||
|
||||
| Collaboration artifact | SHA-256 |
|
||||
| --- | --- |
|
||||
| POM | `cc12f59cbdfd4bc53ba545a9f2b6d7c14a316034b107ef5541ddc48df45fe6af` |
|
||||
| JAR | `284fa370504dd82cee8c87d2b8f5c0cecba8c4b15b32a4f9d3d62a0a062e549d` |
|
||||
|
||||
On October 26, an independent Java 21 consumer downloaded this pair into an
|
||||
initially empty Maven repository and compiled both `TagValueProvider` and
|
||||
`CustomFieldValueProvider` as `VirtualFieldValueProviderInterface` instances.
|
||||
Maven recorded Sonatype `central-portal-snapshots` as the artifact source. The
|
||||
downloaded collaboration POM pins the core version above; the independently
|
||||
downloaded core JAR matches the receipt above. This checks published dependency
|
||||
resolution and API compatibility; runtime behavior is covered by the 198-test
|
||||
verification below, not by this compilation probe.
|
||||
|
||||
This snapshot does not include the separate attachment, My Work or presence
|
||||
recovery follow-up PRs. It has not been deployed to Kof22.
|
||||
|
||||
Earlier local verification used core artifacts installed as
|
||||
`4.1.0-values1196-SNAPSHOT`, with
|
||||
`./mvnw.cmd -Dqqq.snapshot.version=4.1.0-values1196-SNAPSHOT verify`.
|
||||
That version exists only in the local Maven repository; it is not an adoption pin.
|
||||
|
||||
## Behavior
|
||||
|
||||
- `collaborationTags` returns sorted, distinct approved labels from global or
|
||||
matching-table vocabulary. Merged tags and deleted assignments are excluded.
|
||||
The picker and filters retain their tag IDs; exports contain one Tags column.
|
||||
- EAV custom fields return typed existing values and ordinary possible-value
|
||||
display labels. Missing values are null internally and omitted from HTTP value
|
||||
maps. This change does not enable EAV writes.
|
||||
- Providers process batches and use the originating transaction. Parent record
|
||||
authorization and field visibility remain enforced by core. Existing host
|
||||
post-query customizers remain installed.
|
||||
- SQL DISTINCT on provider values is explicitly unsupported. Backend primary-key
|
||||
identity is required; joined heavy primary keys must be fetched explicitly.
|
||||
|
||||
## Release sequence
|
||||
|
||||
1. Publish and verify the coordinated core artifact containing the provider API (done).
|
||||
2. Replace this branch's core BOM pin with that published version (done).
|
||||
3. Run full collaboration verification against the published prerequisite
|
||||
(done October 26: 198 tests, no failures/errors/skips; packaging and coverage
|
||||
passed, including HTTP, PostgreSQL transaction and Redis tests).
|
||||
4. Publish the collaboration snapshot and verify an independent consumer using
|
||||
published artifacts (done; exact pair recorded in CANDIDATE-RELEASE.md).
|
||||
5. Update Kof22 Website-Backend adoption work and qualify it in the selected
|
||||
isolated staging environment before requesting production deployment.
|
||||
|
||||
The published pair contains this implementation. Kof22 adoption, a compatible
|
||||
packaged frontend and staging qualification remain separate release steps.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Recovery cost diagnostic — October 26, 2026
|
||||
|
||||
One Java 21 process, one Redis connection, disposable loopback Redis 7.4-alpine,
|
||||
1,000 issued leases, ten viewers per record, no TLS or HTTP. The probe keeps
|
||||
all leases alive through the real 45-second recovery window and verifies that
|
||||
the final table snapshot contains all 1,000 viewers. Each phase samples 200
|
||||
renewals with increasing sequence numbers. This is not a capacity or SLA claim.
|
||||
|
||||
| Renewal latency (ms) | During recovery | Ready |
|
||||
|---|---:|---:|
|
||||
| p50 | 3.6779 | 0.5134 |
|
||||
| p95 | 4.1956 | 0.7226 |
|
||||
| p99 | 4.5447 | 0.9122 |
|
||||
| Maximum | 6.8271 | 1.4886 |
|
||||
|
||||
Population took 4,640.799 ms. The run passed all correctness checks. This first
|
||||
implementation scanned touched projections on every request during recovery.
|
||||
|
||||
After changing table cleanup to occur atomically only when creating its recovery
|
||||
generation (individual record cleanup remains lazy), the same diagnostic passed:
|
||||
|
||||
| Renewal latency (ms) | Optimized recovery | Ready |
|
||||
|---|---:|---:|
|
||||
| p50 | 0.6375 | 0.5029 |
|
||||
| p95 | 0.8150 | 0.5762 |
|
||||
| p99 | 0.9406 | 0.7500 |
|
||||
| Maximum | 4.2280 | 1.2251 |
|
||||
|
||||
Optimized population took 1,634.486 ms. All 1,000 leases survived. The focused
|
||||
recovery/store suite also passed 13 tests, including guard loss with previously
|
||||
unvisited record rooms. Full optimized verification passed 184 tests with no failures/errors/skips and coverage gates passed. These are
|
||||
individual local runs and need further qualification before claiming supported
|
||||
production capacity. They do not isolate every source
|
||||
of latency, compare against an earlier release, or measure sustained concurrent
|
||||
load, saturation, TLS, HTTP or browser propagation.
|
||||
|
||||
At 10,000 entries the optimized diagnostic also passed, retaining all leases.
|
||||
Population took 10,687.2105 ms; the same 200-sample phases measured:
|
||||
|
||||
| Renewal latency (ms) | Optimized recovery, 10,000 entries | Ready |
|
||||
|---|---:|---:|
|
||||
| p50 | 0.4978 | 0.5341 |
|
||||
| p95 | 0.5914 | 0.7108 |
|
||||
| p99 | 0.7255 | 1.2964 |
|
||||
| Maximum | 0.7544 | 1.8799 |
|
||||
|
||||
Do not infer that larger tables are faster from these separate local runs.
|
||||
JIT warmup, scheduling and concurrent machine activity were not controlled.
|
||||
The useful result is that the optimization avoids a repeated whole-table scan
|
||||
during recovery and both populated-table runs preserved lease correctness.
|
||||
|
||||
The explicit `RedisPresenceRecoveryCostProbe` test-source main class is not
|
||||
automatically run by the unit suite and imposes no machine-dependent CI gate.
|
||||
After test compilation, run it on the test classpath with argument `1000`.
|
||||
It fails if the real recovery window ends before the cold measurement, rather
|
||||
than silently reporting ready-state latency as recovery latency.
|
||||
@@ -1,52 +0,0 @@
|
||||
// Real browser recovery against the disposable two-JVM harness. No API mocks.
|
||||
import { createRequire } from 'node:module'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
|
||||
const [frontend, first, second, container, output, profile = 'chromium'] = process.argv.slice(2)
|
||||
const require = createRequire(path.join(frontend, 'package.json'))
|
||||
const { chromium, firefox, webkit, devices, expect } = require('@playwright/test')
|
||||
const profiles = {
|
||||
chromium: [chromium, devices['Desktop Chrome']],
|
||||
firefox: [firefox, devices['Desktop Firefox']],
|
||||
webkit: [webkit, devices['Desktop Safari']],
|
||||
mobile: [chromium, devices['Pixel 7']],
|
||||
tablet: [webkit, devices['iPad (gen 7)']],
|
||||
}
|
||||
if (!profiles[profile]) throw new Error(`Unknown browser profile: ${profile}`)
|
||||
const [engine, device] = profiles[profile]
|
||||
const browser = await engine.launch({ headless: true })
|
||||
const contexts = []
|
||||
try {
|
||||
const pages = []
|
||||
for (const [port, user] of [[first, 'alice'], [second, 'bob']]) {
|
||||
const context = await browser.newContext(device)
|
||||
contexts.push(context)
|
||||
await context.tracing.start({ screenshots: true, snapshots: true })
|
||||
const page = await context.newPage()
|
||||
await page.goto(`http://127.0.0.1:${port}/?createMockSession=true&user=${user}`)
|
||||
// The root export redirects after hydration; finish it before record navigation.
|
||||
await page.waitForURL(url => /^\/app\/?$/.test(url.pathname), { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(`http://127.0.0.1:${port}/app/person/1`)
|
||||
pages.push(page)
|
||||
}
|
||||
const unavailable = page => page.getByText('Presence temporarily unavailable', { exact: true })
|
||||
await expect(pages[0].locator('[data-qqq-id="presence-bob"]')).toBeVisible({ timeout: 90000 })
|
||||
await expect(pages[1].locator('[data-qqq-id="presence-alice"]')).toBeVisible({ timeout: 90000 })
|
||||
execFileSync('docker', ['exec', container, 'redis-cli', 'FLUSHDB'])
|
||||
for (const page of pages) await expect(unavailable(page)).toBeVisible({ timeout: 35000 })
|
||||
await expect(pages[0].locator('[data-qqq-id="presence-bob"]')).toHaveCount(0)
|
||||
await expect(pages[1].locator('[data-qqq-id="presence-alice"]')).toHaveCount(0)
|
||||
await expect(pages[0].locator('[data-qqq-id="presence-bob"]')).toBeVisible({ timeout: 90000 })
|
||||
await expect(pages[1].locator('[data-qqq-id="presence-alice"]')).toBeVisible({ timeout: 90000 })
|
||||
for (const [index, page] of pages.entries()) {
|
||||
await expect(unavailable(page)).toHaveCount(0)
|
||||
await page.screenshot({ path: path.join(output, `recovered-${index}.png`) })
|
||||
}
|
||||
console.log(`PASS: ${profile} shows recovery unavailable, clears stale people, and rejoins both JVMs without reload.`)
|
||||
} finally {
|
||||
for (const [index, context] of contexts.entries()) {
|
||||
await context.tracing.stop({ path: path.join(output, `browser-${index}.zip`) })
|
||||
}
|
||||
await browser.close()
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
"""Exercise Redis loss through two independent SharedPresenceDevServer JVMs.
|
||||
|
||||
Run after test-compile and dependency:build-classpath with test scope and output
|
||||
target/recovery-process-classpath.txt. Uses only disposable loopback fixtures.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
|
||||
def free_port():
|
||||
"""Reserve a candidate loopback port; startup detects any bind race."""
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
def request(port, user, method, path, body=None):
|
||||
"""Use the fixture's mock session through the actual HTTP boundary."""
|
||||
separator = "&" if "?" in path else "?"
|
||||
url = f"http://127.0.0.1:{port}/qqq/v1/qbit/collaboration{path}{separator}user={user}"
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||
"Cookie": f"sessionUUID={user}-recovery-processes",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
try:
|
||||
response = urllib.request.urlopen(req, timeout=10)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
return response.status, json.load(response)
|
||||
|
||||
|
||||
def require(status, result):
|
||||
"""Fail with fixture-only response evidence when transport differs."""
|
||||
assert result[0] == status, result
|
||||
return result[1]
|
||||
|
||||
|
||||
def settle(ports, leases):
|
||||
"""Renew on the opposite node until both nodes report both people."""
|
||||
deadline = time.monotonic() + 60
|
||||
sequence = 0
|
||||
while time.monotonic() < deadline:
|
||||
sequence += 1
|
||||
for index, (user, lease) in enumerate(leases.items()):
|
||||
result = request(ports[1 - index], user, "POST", "/presence/leases/renew", {
|
||||
"connectionId": lease, "table": "person", "pk": "1",
|
||||
"mode": "VIEWING", "sequence": sequence,
|
||||
})
|
||||
assert result[0] in (200, 503), result
|
||||
if result[0] == 503:
|
||||
assert "recovering" in result[1]["message"], result
|
||||
snapshots = [request(port, "alice", "GET", "/presence?table=person&pk=1") for port in ports]
|
||||
if all(status == 200 for status, _ in snapshots):
|
||||
assert all(len(body["viewers"]) == 2 for _, body in snapshots), snapshots
|
||||
return
|
||||
assert all(status == 503 for status, _ in snapshots), snapshots
|
||||
time.sleep(1)
|
||||
raise AssertionError("Two-process recovery exceeded 60 seconds")
|
||||
|
||||
|
||||
def main():
|
||||
"""Own and clean up the exact child JVMs and disposable Redis container."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java", required=True)
|
||||
parser.add_argument("--repo", type=Path, required=True)
|
||||
parser.add_argument("--frontend-repo", type=Path)
|
||||
parser.add_argument("--frontend-jar", type=Path, help="Serve this packaged frontend instead of a local export")
|
||||
parser.add_argument("--backend-jar", type=Path, help="Use this backend artifact instead of target/classes")
|
||||
parser.add_argument("--browser-profile", choices=("chromium", "firefox", "webkit", "mobile", "tablet"), default="chromium")
|
||||
args = parser.parse_args()
|
||||
repo = args.repo.resolve()
|
||||
output = repo / "target" / ("recovery-processes-" + uuid.uuid4().hex[:10])
|
||||
output.mkdir()
|
||||
if args.frontend_jar and not args.frontend_repo:
|
||||
parser.error("frontend-jar requires frontend-repo for browser tooling")
|
||||
dependencies = (repo / "target/recovery-process-classpath.txt").read_text().strip().split(os.pathsep)
|
||||
assert not any(Path(item).name.startswith(("qbit-collaboration-", "qqq-frontend-next-")) for item in dependencies)
|
||||
backend = args.backend_jar.resolve(strict=True) if args.backend_jar else repo / "target/classes"
|
||||
classpath = os.pathsep.join([str(repo / "target/test-classes"), str(backend), *dependencies])
|
||||
evidence = {"status": "FAIL", "browser_profile": args.browser_profile, "cleanup": False}
|
||||
if args.backend_jar:
|
||||
evidence["backend_jar_sha256"] = hashlib.sha256(backend.read_bytes()).hexdigest()
|
||||
if args.frontend_jar:
|
||||
frontend = args.frontend_jar.resolve(strict=True)
|
||||
evidence["frontend_jar_sha256"] = hashlib.sha256(frontend.read_bytes()).hexdigest()
|
||||
classpath = str(frontend) + os.pathsep + classpath
|
||||
elif args.frontend_repo:
|
||||
shutil.copytree(args.frontend_repo.resolve() / "out", output / "next-dashboard")
|
||||
classpath = str(output) + os.pathsep + classpath
|
||||
container = "qqq-recovery-" + uuid.uuid4().hex[:12]
|
||||
processes, logs = [], []
|
||||
try:
|
||||
subprocess.run(["docker", "run", "-d", "--name", container, "-p", "127.0.0.1::6379", "redis:7.4-alpine"], check=True, capture_output=True)
|
||||
binding = subprocess.check_output(["docker", "port", container, "6379"], text=True).strip()
|
||||
redis_port = int(binding.rsplit(":", 1)[1])
|
||||
ports = [free_port(), free_port()]
|
||||
for port in ports:
|
||||
argfile = output / f"node-{port}.args"
|
||||
argfile.write_text('-Dcollab.dev.frontend=' + str(bool(args.frontend_repo)).lower() + '\n-cp\n"' + classpath.replace("\\", "/") + '"\n'
|
||||
'com.kingsrook.qbits.collaboration.devserver.SharedPresenceDevServer\n'
|
||||
f'{port}\n{redis_port}\n', encoding="utf-8")
|
||||
log = (output / f"node-{port}.log").open("w")
|
||||
logs.append(log)
|
||||
processes.append(subprocess.Popen([args.java, "@" + str(argfile)], cwd=repo,
|
||||
stdout=log, stderr=subprocess.STDOUT,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)))
|
||||
deadline = time.monotonic() + 60
|
||||
for port, process in zip(ports, processes):
|
||||
while f"SHARED_PRESENCE_READY {port}" not in (output / f"node-{port}.log").read_text(errors="replace"):
|
||||
assert process.poll() is None, f"Node exited; see {output}"
|
||||
assert time.monotonic() < deadline, f"Startup timed out; see {output}"
|
||||
time.sleep(0.2)
|
||||
if args.frontend_repo:
|
||||
subprocess.run(["node", str(repo / "docs/acceptance/presence_recovery_browser.mjs"),
|
||||
str(args.frontend_repo.resolve()), str(ports[0]), str(ports[1]), container, str(output), args.browser_profile], check=True)
|
||||
evidence["status"] = "PASS"
|
||||
return
|
||||
def join():
|
||||
"""Acquire each person's new lease on a different node."""
|
||||
return {user: require(200, request(port, user, "POST", "/presence/leases", {
|
||||
"table": "person", "pk": "1"}))["connectionId"] for port, user in zip(ports, ("alice", "bob"))}
|
||||
original = join()
|
||||
settle(ports, original)
|
||||
subprocess.run(["docker", "exec", container, "redis-cli", "FLUSHDB"], check=True, capture_output=True)
|
||||
for port in ports:
|
||||
require(503, request(port, "alice", "GET", "/presence?table=person&pk=1"))
|
||||
for user, lease in original.items():
|
||||
error = require(409, request(port, user, "POST", "/presence/leases/renew", {
|
||||
"connectionId": lease, "table": "person", "pk": "1", "mode": "VIEWING", "sequence": 1000}))
|
||||
assert error["code"] == "PRESENCE_LEASE_EXPIRED", error
|
||||
replacement = join()
|
||||
assert all(replacement[user] != original[user] for user in original)
|
||||
settle(ports, replacement)
|
||||
print(f"PASS: two independent JVMs recovered two viewers after Redis loss. Logs: {output}")
|
||||
evidence["status"] = "PASS"
|
||||
except Exception as error:
|
||||
evidence["failure"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
for process in processes:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
for log in logs:
|
||||
log.close()
|
||||
cleanup = subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False)
|
||||
evidence["cleanup"] = cleanup.returncode == 0 and all(process.poll() is not None for process in processes)
|
||||
(output / "result.json").write_text(json.dumps(evidence, indent=2), encoding="utf-8")
|
||||
print(output, flush=True)
|
||||
if evidence["status"] == "PASS" and not evidence["cleanup"]:
|
||||
raise RuntimeError("Recovery passed but owned fixture cleanup failed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Restore a disposable Redis image while both fixture JVMs are stopped.
|
||||
|
||||
This verifies the controlled-restore protocol, not production orchestration.
|
||||
Requires the same compiled test classpath as presence_recovery_processes.py.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from presence_recovery_processes import free_port, request, require, settle
|
||||
|
||||
|
||||
def main():
|
||||
"""Own a Redis fixture and two generations of independently running nodes."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java", required=True)
|
||||
parser.add_argument("--repo", type=Path, required=True)
|
||||
parser.add_argument("--backend-jar", type=Path, help="Verify this artifact without production target/classes")
|
||||
args = parser.parse_args()
|
||||
repo = args.repo.resolve()
|
||||
output = repo / "target" / ("restore-processes-" + uuid.uuid4().hex[:10])
|
||||
output.mkdir()
|
||||
dependencies = (repo / "target/recovery-process-classpath.txt").read_text().strip().split(os.pathsep)
|
||||
assert all(Path(item).is_file() and item.endswith('.jar') for item in dependencies)
|
||||
assert not any(Path(item).name.startswith("qbit-collaboration-") for item in dependencies)
|
||||
backend = args.backend_jar.resolve(strict=True) if args.backend_jar else repo / "target/classes"
|
||||
classpath = os.pathsep.join([str(repo / "target/test-classes"), str(backend), *dependencies])
|
||||
evidence = {"status": "FAIL", "cleanup": False, "checks": []}
|
||||
if args.backend_jar:
|
||||
evidence["backend_jar_sha256"] = hashlib.sha256(backend.read_bytes()).hexdigest()
|
||||
container = "qqq-restore-" + uuid.uuid4().hex[:12]
|
||||
container_created = False
|
||||
processes, logs = [], []
|
||||
ports = [free_port(), free_port()]
|
||||
|
||||
def redis(*command):
|
||||
"""Run commands only against this owned, disposable Redis container."""
|
||||
return subprocess.check_output(["docker", "exec", container, "redis-cli", *command], text=True).strip()
|
||||
|
||||
def stop_nodes():
|
||||
"""Require process termination and closed old listeners before restore."""
|
||||
for process in processes:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
processes.clear()
|
||||
for port in ports:
|
||||
with socket.socket() as connection:
|
||||
connection.settimeout(1)
|
||||
assert connection.connect_ex(("127.0.0.1", port)) != 0, "Old listener remains open"
|
||||
|
||||
def start_nodes(generation, redis_port):
|
||||
"""Supply the shared generation outside the Redis image, on both JVMs."""
|
||||
for port in ports:
|
||||
argfile = output / f"{generation}-{port}.args"
|
||||
argfile.write_text(f'-Dcollab.dev.recoveryGeneration={generation}\n-cp\n"'
|
||||
+ classpath.replace("\\", "/") + '"\n'
|
||||
'com.kingsrook.qbits.collaboration.devserver.SharedPresenceDevServer\n'
|
||||
f'{port}\n{redis_port}\n', encoding="utf-8")
|
||||
logfile = output / f"{generation}-{port}.log"
|
||||
log = logfile.open("w")
|
||||
logs.append(log)
|
||||
processes.append(subprocess.Popen([args.java, "@" + str(argfile)], cwd=repo,
|
||||
stdout=log, stderr=subprocess.STDOUT,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)))
|
||||
deadline = time.monotonic() + 60
|
||||
for port, process in zip(ports, processes):
|
||||
logfile = output / f"{generation}-{port}.log"
|
||||
while f"SHARED_PRESENCE_READY {port}" not in logfile.read_text(errors="replace"):
|
||||
assert process.poll() is None, f"Node exited; see {output}"
|
||||
assert time.monotonic() < deadline, f"Startup timed out; see {output}"
|
||||
time.sleep(0.2)
|
||||
|
||||
def join():
|
||||
"""Issue each person's lease on a separate node."""
|
||||
return {user: require(200, request(port, user, "POST", "/presence/leases", {
|
||||
"table": "person", "pk": "1"}))["connectionId"] for port, user in zip(ports, ("alice", "bob"))}
|
||||
|
||||
def reject_old(leases, sequence):
|
||||
"""Restored lease authority must fail on both new-generation nodes."""
|
||||
for port in ports:
|
||||
for user, lease in leases.items():
|
||||
error = require(409, request(port, user, "POST", "/presence/leases/renew", {
|
||||
"connectionId": lease, "table": "person", "pk": "1",
|
||||
"mode": "VIEWING", "sequence": sequence}))
|
||||
assert error["code"] == "PRESENCE_LEASE_EXPIRED", error
|
||||
|
||||
try:
|
||||
# DEBUG RELOAD is enabled only on this disposable loopback fixture.
|
||||
subprocess.run(["docker", "run", "-d", "--name", container, "-p", "127.0.0.1::6379",
|
||||
"redis:7.4-alpine", "redis-server", "--enable-debug-command", "yes", "--save", ""],
|
||||
check=True, capture_output=True)
|
||||
container_created = True
|
||||
binding = subprocess.check_output(["docker", "port", container, "6379"], text=True).strip()
|
||||
redis_port = int(binding.rsplit(":", 1)[1])
|
||||
start_nodes("before-restore", redis_port)
|
||||
original = join()
|
||||
settle(ports, original)
|
||||
last_renewal = time.monotonic()
|
||||
for port, (user, lease) in zip(ports, original.items()):
|
||||
require(200, request(port, user, "POST", "/presence/leases/renew", {
|
||||
"connectionId": lease, "table": "person", "pk": "1",
|
||||
"mode": "VIEWING", "sequence": 100}))
|
||||
assert redis("SAVE") == "OK"
|
||||
before = int(redis("DBSIZE"))
|
||||
assert before > 0
|
||||
stop_nodes()
|
||||
evidence["checks"].append("old JVMs stopped and listeners closed before restore")
|
||||
assert redis("FLUSHDB") == "OK"
|
||||
assert redis("DBSIZE") == "0"
|
||||
assert redis("DEBUG", "RELOAD", "NOSAVE") == "OK"
|
||||
assert int(redis("DBSIZE")) > 0, "Saved Redis image was not restored"
|
||||
evidence["checks"].append("nonempty saved Redis image restored")
|
||||
start_nodes("restored-" + uuid.uuid4().hex, redis_port)
|
||||
reject_old(original, 1000)
|
||||
# Avoid a false pass caused only by the old 45-second lease expiring.
|
||||
evidence["old_lease_rejection_seconds"] = time.monotonic() - last_renewal
|
||||
assert evidence["old_lease_rejection_seconds"] < 40, "Restore exceeded the live-lease proof window"
|
||||
evidence["checks"].append("both nodes reject old leases before normal expiry")
|
||||
for port in ports:
|
||||
result = require(503, request(port, "alice", "GET", "/presence?table=person&pk=1"))
|
||||
assert "recovering" in result["message"], result
|
||||
replacement = join()
|
||||
assert all(replacement[user] != original[user] for user in original)
|
||||
settle(ports, replacement)
|
||||
reject_old(original, 1001)
|
||||
evidence["checks"].append("both nodes recover with fresh viewers and still reject old leases")
|
||||
if args.backend_jar:
|
||||
assert hashlib.sha256(backend.read_bytes()).hexdigest() == evidence["backend_jar_sha256"]
|
||||
evidence["status"] = "PASS"
|
||||
print(f"PASS: restored Redis image cannot authorize old leases on either new-generation JVM. Logs: {output}")
|
||||
except Exception as error:
|
||||
evidence["failure"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
try:
|
||||
stop_nodes()
|
||||
finally:
|
||||
for log in logs:
|
||||
log.close()
|
||||
if container_created:
|
||||
subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=True)
|
||||
evidence["cleanup"] = True
|
||||
except Exception as error:
|
||||
evidence["status"] = "FAIL"
|
||||
evidence["cleanup_failure"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
(output / "result.json").write_text(json.dumps(evidence, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,264 +0,0 @@
|
||||
# Independent-node presence workload
|
||||
|
||||
This fixture targets QQQ #1302/#1291. It runs 2–8 independent JVM HTTP servers
|
||||
against an owned loopback Redis container. Every node serves the same configured
|
||||
mock identities across 2–8 tenant scopes. The scope comes from the authenticated
|
||||
fixture identity, never a supplied tenant parameter. Records are independently
|
||||
seeded in memory; this does not qualify shared database or production IAM behavior.
|
||||
|
||||
Build with Java 21:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd test-compile dependency:build-classpath '-Dmdep.includeScope=test' '-Dmdep.outputFile=target/capacity-classpath.txt'
|
||||
python docs/performance/presence_nodes.py --java /path/to/java --nodes 3 --tenants 2 --users 6 --tabs 4 --rounds 100
|
||||
```
|
||||
|
||||
Four tabs per user means two duplicate tabs on each of two records. Every round
|
||||
renews each issued lease on a rotating node, with sequential per-tab sequences.
|
||||
All nodes must then return exactly the expected tenant's distinct people and
|
||||
exactly two editors on both records. Any mismatch or request failure fails the
|
||||
run. Requests use bounded worker concurrency and ten-second timeouts. Each node
|
||||
has eight pooled Redis connections, a 500 ms pool wait and a 384 MiB heap ceiling.
|
||||
|
||||
Results and per-node logs are retained under `target/capacity-nodes-<id>`.
|
||||
The result records parameters, node PIDs, elapsed time, sample counts and
|
||||
nearest-rank HTTP p50/p95/p99/max. Renewal throughput includes snapshot checks
|
||||
and is not maximum server throughput. All owned JVMs and the Redis container are
|
||||
removed in `finally`; the receipt reports cleanup separately from test success.
|
||||
|
||||
Each fixture JVM emits resource samples every 100 ms using Java management APIs
|
||||
and the Redis QBit's existing pool metrics. The receipt summarizes sampled heap
|
||||
used/committed bytes, active/idle/waiting pool counts and the configured maximum.
|
||||
CPU core equivalents are process CPU time divided by observed wall time; they
|
||||
can exceed one for a multithreaded JVM. Observation begins after server startup
|
||||
and includes lease setup and workload execution. Unsupported CPU counters are
|
||||
reported as null. Linux fixtures also read process `VmRSS` from `/proc/self/status`
|
||||
every 100 ms and record `residentBytes_sampled_max`; unsupported platforms report
|
||||
null. Linux-container runs require resident-memory samples. Heap is not total
|
||||
resident memory, and process RSS is not the container's complete cgroup footprint.
|
||||
Sampled peaks may miss
|
||||
short bursts and must not be represented as exact maxima. Raw samples remain in
|
||||
node logs; missing node samples or a sampled pool-bound violation fail the run.
|
||||
|
||||
The final Redis INFO capture records `used_memory`, lifetime `used_memory_peak`,
|
||||
and current `used_memory_rss`, plus process user/system CPU seconds since Redis
|
||||
startup. Redis RSS is an end-of-run observation, not a sampled peak. These fresh,
|
||||
owned Redis instances include fixture setup and diagnostics in their lifetime;
|
||||
do not interpret those CPU values as workload-only measurements or add independent
|
||||
process peaks and call the sum a measured simultaneous maximum. Missing Redis
|
||||
memory/CPU diagnostics fail acceptance while cleanup still runs.
|
||||
|
||||
The [100-user resource run](results/linux-100-users-resources.json) used three
|
||||
Linux JVMs, two tenants with 50 people each, four leases per person, 16 request
|
||||
workers and 200 rounds. All 80,000 renewals and 2,400 exact-membership snapshots
|
||||
passed in 102.568 workload seconds. Renewal p50/p95/p99/max were
|
||||
11.326/21.765/25.881/1,007.404 ms; snapshot p99 was 19.424 ms. The one-second
|
||||
renewal maximum is retained, not discarded. Cleanup passed.
|
||||
|
||||
Sampled JVM RSS peaks were 662,786,048 / 662,188,032 / 703,479,808 bytes
|
||||
(approximately 632–671 MiB), while used-heap peaks were about 171–177 MiB.
|
||||
CPU observations were 1.73–1.83 core equivalents per JVM; sampled pool-active
|
||||
peaks were 7/7/6 of eight with no sampled waiters. Redis peak allocated memory
|
||||
was 2,579,896 bytes; final RSS was 8,777,728 bytes. Redis process CPU since
|
||||
startup was 23.738 system plus 35.526 user seconds. These measurements include
|
||||
fixture overhead, omit browser load, and do not establish deployment limits.
|
||||
|
||||
Initial smoke run: three JVMs, two tenants, six users each, four tabs per user,
|
||||
three rounds: 144 renewals and 36 snapshots passed with tenant isolation,
|
||||
duplicate-tab deduplication and two editors retained. Cleanup passed. This short
|
||||
run validates the harness, not sustained capacity or operational thresholds.
|
||||
|
||||
A second run with 12 users per tenant and 100 rounds passed 9,600 renewals and
|
||||
1,200 snapshots in 23.110 seconds. Renewal p50/p95/p99 were 7.246/16.454/21.853 ms;
|
||||
snapshot p50/p95/p99 were 12.908/15.776/18.239 ms. Every round checked exact
|
||||
tenant membership, deduplication and two editors on both records at every node.
|
||||
Cleanup passed. These are measurements from one local Windows/Docker run;
|
||||
they do not establish deployment limits or browser propagation latency.
|
||||
|
||||
## Sustained-run limitation under investigation
|
||||
|
||||
The 1,000-round workload has intermittently failed with HTTP 503 on lease
|
||||
renewal. Four runs failed; one completed 96,000 renewals and 12,000 snapshots.
|
||||
The passing run does not resolve the failures. Temporary local diagnostics on
|
||||
the third failure identified `JedisConnectionException` caused by
|
||||
`SocketTimeoutException` while reading the `EVALSHA` response in
|
||||
`RedisPresenceStore.mutate`. The configured socket timeout remained two seconds.
|
||||
The temporary production-file diagnostics were removed after identifying this
|
||||
path. No timeout increase, hidden request retry or production fix is included.
|
||||
|
||||
Resource samples did not show pool waiters, but cannot rule out short bursts.
|
||||
Redis slow-command timings and latency events are now captured before fixture
|
||||
cleanup, alongside Redis CPU, memory and command counters, to distinguish server
|
||||
execution cost from transport or scheduling delays. Slow-command summaries omit
|
||||
command arguments. These diagnostics belong only to the disposable fixture.
|
||||
Until the cause is established and qualified, this workload establishes no
|
||||
supported sustained throughput or production alert thresholds.
|
||||
|
||||
The timing-instrumented Windows-JVM run failed at round 480, with no commands
|
||||
at or above the configured one-millisecond Redis slow-log threshold and no
|
||||
latency-monitor events. Redis reported about 93 microseconds per `EVALSHA` on
|
||||
average. This weakens a slow-Lua explanation but does not identify the delayed
|
||||
component or exclude time spent outside command execution.
|
||||
|
||||
## Linux comparison
|
||||
|
||||
`--node-image eclipse-temurin:21-jre` optionally runs each JVM in its own Linux
|
||||
container on an owned Docker network shared with Redis. The HTTP driver remains
|
||||
on the host. Only loopback HTTP ports are published. Compiled fixture classes
|
||||
and copied dependency JARs are mounted read-only; Redis remains disposable.
|
||||
The existing loopback defaults remain unchanged when this option is absent.
|
||||
Container mode records Docker client PIDs separately from container identities.
|
||||
It removes the Windows-JVM-to-published-Redis-port path, but also changes the JVM
|
||||
platform and scheduling environment; it is a diagnostic comparison, not proof
|
||||
of a specific operating-system or Docker defect. Cleanup includes every owned
|
||||
node container and the owned network.
|
||||
|
||||
`--churn-every 20` departs all tabs belonging to the last user in each tenant
|
||||
every 20 rounds. Every departed lease must reject a later renewal with HTTP 409
|
||||
and `PRESENCE_LEASE_EXPIRED` on another node. All surviving nodes must show that
|
||||
user absent on both records, then show the original exact membership after
|
||||
fresh, distinct leases are acquired and activated. Only this explicit rejoin
|
||||
path acquires new leases; the node-crash path preserves existing ones.
|
||||
|
||||
`--pause-redis` adds a controlled failure phase after the ordinary rounds. It
|
||||
freezes only the owned Redis container, sends up to 64 concurrent renewal
|
||||
requests, and requires HTTP 503 within an eight-second fixture bound. Snapshots
|
||||
must also fail explicitly rather than return an empty room. Redis is unpaused
|
||||
in `finally`; once reachable, higher-sequence renewals must recover the same
|
||||
leases and exact membership. The test requires observed pool waiters and rejects
|
||||
sampled active counts above the configured maximum. It does not retry failed
|
||||
requests invisibly or raise production timeouts.
|
||||
|
||||
The [Linux Redis-stall receipt](results/linux-redis-stall.json) passed with three
|
||||
nodes, two tenants, 24 users and 96 tab leases. All 96 stalled renewals and all
|
||||
three node snapshots returned the expected unavailable response. Existing
|
||||
leases recovered after Redis resumed, and cleanup passed. This proves bounded
|
||||
failure and recovery for an injected stall; it does not explain the intermittent
|
||||
Windows timeout or establish a saturation throughput threshold.
|
||||
|
||||
```powershell
|
||||
python docs/performance/presence_nodes.py --java java --node-image eclipse-temurin:21-jre --nodes 3 --tenants 2 --users 12 --tabs 4 --rounds 1000
|
||||
```
|
||||
|
||||
The first Linux comparison completed 1,000 rounds in 250.811 seconds: 96,000
|
||||
renewals and 12,000 snapshots, with exact membership/editor assertions and
|
||||
cleanup passing. Renewal p95/p99 were 15.331/17.760 ms. The sampled active pool
|
||||
peak was five of eight, with no sampled waiters. This one run is separate from
|
||||
the unresolved Windows failures and does not establish a production SLA.
|
||||
|
||||
`--crash-round 50` abruptly kills the first node before that round, then sends
|
||||
all existing leases through surviving nodes. It does not acquire replacement
|
||||
leases or hide failed requests with retries. Exact tenant membership, duplicate
|
||||
tabs and editor counts must remain correct on every surviving node. This tests
|
||||
HTTP lease continuity after process loss, not browser/SSE reconnect, ingress
|
||||
failover, database durability or a Redis outage.
|
||||
|
||||
The Linux crash run passed 100 rounds (9,600 renewals and 996 snapshots) in
|
||||
25.148 seconds. The first JVM exited with code 137 at round 50; the remaining
|
||||
two retained all issued leases and expected membership. Cleanup passed.
|
||||
|
||||
A [combined churn and node-loss run](results/linux-node-crash-churn.json) passed
|
||||
100 rounds with the first node killed at round 50 and churn every 20 rounds.
|
||||
Five cycles rejected all 40 departed handles and checked absence/restored
|
||||
membership across both tenants and records on every surviving node. Cleanup
|
||||
passed. This is bounded HTTP lifecycle qualification; browser reconnect and
|
||||
missed-event behavior remain separate requirements.
|
||||
|
||||
Recorded receipts retain both successful and failing evidence:
|
||||
|
||||
- [Windows timeout with Redis diagnostics](results/windows-timeout.json)
|
||||
- [Linux 1,000-round run](results/linux-1000-rounds.json)
|
||||
- [Linux node crash](results/linux-node-crash.json)
|
||||
|
||||
Only the Java executable path is normalized in these receipts. The Linux image
|
||||
used for these runs was
|
||||
`eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99`.
|
||||
|
||||
## Browser propagation baseline
|
||||
|
||||
`browser_subscription_affinity.mjs FRONTEND_REPO BASE_URL RESULT_PATH` verifies
|
||||
the current owning-node subscription contract against the two-node fixture. It
|
||||
uses the connection ID from the real browser's successful subscription request,
|
||||
then preserves the same session cookies while changing only the test routing
|
||||
cookie. The [published-artifact receipt](results/browser-subscription-affinity.json)
|
||||
records owner 200, other node 404, owner 200, with the requested channel active
|
||||
on both successful responses and the unsaved draft retained. This proves why
|
||||
subscription requests need owning-node routing even when presence state is shared.
|
||||
It does not test the deployed Traefik cookie, HTTPS, buffering or failover.
|
||||
The packaged runner accepts this script through `--affinity-script`.
|
||||
|
||||
`browser_presence_latency.mjs` measures Chromium field-focus changes until a
|
||||
different editor sees the corresponding remote marker. Supply a caller-owned
|
||||
two-node loopback fixture serving the published frontend with `alice`/`admin`
|
||||
mock identities and the acceptance router's `presence_node=first/second`
|
||||
affinity cookies:
|
||||
|
||||
```powershell
|
||||
node docs/performance/browser_presence_latency.mjs /path/to/frontend http://localhost:PORT result.json 25
|
||||
```
|
||||
|
||||
Use `presence_router.mjs FIRST_NODE_PORT SECOND_NODE_PORT ROUTER_PORT` for this
|
||||
test. It binds only to loopback and adds a test-only POST endpoint that severs
|
||||
first-node SSE responses. Never deploy this fault-injection router. The packaged
|
||||
acceptance fixture accepts it through `--router-script` alongside `--latency-script`.
|
||||
|
||||
The test verifies the target marker is absent before every change, the old
|
||||
marker disappears afterward, both real SSE streams opened successfully,
|
||||
issued-lease writes reached distinct nodes, and unsaved drafts survive. API
|
||||
responses are not mocked. The fixture owns backend/router/Redis cleanup; this
|
||||
script owns and closes its browser.
|
||||
|
||||
After timing the normal transitions, the script takes the observer offline and
|
||||
explicitly severs its existing SSE response through the fixture router. It requires
|
||||
an observed stream failure, a failed offline fetch, three acknowledged sender focus
|
||||
changes, and absence of the new marker while disconnected. After network restoration
|
||||
it requires a new successful SSE response and the current marker with the previous
|
||||
marker removed, without reload or loss of either unsaved draft. Recovery may use
|
||||
the shipped reconnect and polling behavior; this does not prove event replay.
|
||||
|
||||
The [disconnect/recovery receipt](results/browser-reconnect.json) passed against
|
||||
the public RC37 JAR: one stream severed, three missed changes, recovery in 1,858 ms,
|
||||
both drafts preserved. The accompanying six-user and Redis admin checks passed;
|
||||
fixture cleanup passed. This is one Chromium disconnect case without concurrent
|
||||
load, not a reconnect storm, lease-expiry test, or production recovery objective.
|
||||
|
||||
Append `load` to the browser command (or use `--latency-load` on the packaged
|
||||
fixture) to add 16 concurrent HTTP lease workers: four mock identities with four
|
||||
leases each on the same record, split between both nodes. Each worker renews
|
||||
sequentially with a 100 ms pause after each response. The browser observations
|
||||
are spread over at least 60 seconds; every observation interval must include
|
||||
completed background renewals. The workload continues during disconnect/reconnect.
|
||||
Any request or lease-cleanup failure fails the test. Responses are disposed after
|
||||
reading, and workers stop before the contexts close. The receipt reports completed
|
||||
renewals, observed node counts, elapsed time and any errors. These are HTTP load
|
||||
workers, not 16 additional browser tabs or distinct people.
|
||||
|
||||
The [bounded load/reconnect receipt](results/browser-load-reconnect.json) passed
|
||||
9,046 renewals over 69.728 seconds, with no worker or lease-release errors.
|
||||
Across 25 observed focus transitions, p50/p95/p99 were 32.384/85.391/785.495 ms;
|
||||
p99 is the maximum of this small sample, not a reliable tail-latency estimate.
|
||||
The observer recovered in 1,855.887 ms after missing three updates while load
|
||||
continued. Both drafts survived. Six-user and Redis admin acceptance also passed
|
||||
against the public RC37 JAR; fixture cleanup passed. An initial run failed only
|
||||
because the new load helper used the wrong lease-release endpoint; that receipt
|
||||
is retained in the local packaged fixture history, not counted as a passing run.
|
||||
|
||||
The [25-transition receipt](results/browser-focus-25.json) passed against the
|
||||
downloaded public RC37 JAR and published backend snapshots. Focus-to-observed-DOM
|
||||
p50/p95/p99 were 31.648/33.078/36.092 ms; p99 is the maximum in this small sample.
|
||||
There were no page errors or failed lease responses. Six-user presence and Redis
|
||||
admin browser checks also passed in the same fixture run; fixture cleanup passed.
|
||||
|
||||
This baseline includes browser automation, UI debounce, network, invalidation
|
||||
and rendering. It is not a server-only transport measurement, an SSE-only causal
|
||||
proof, an under-load result, or a production latency objective. The separate load
|
||||
receipt above covers one bounded workload and disconnect scenario; larger loads,
|
||||
reconnect storms and deployment-specific behavior remain unqualified.
|
||||
|
||||
Remaining qualification includes longer soak and larger workloads, container
|
||||
resource limits, reconnect storms and the actual deployment's capacity and alert
|
||||
thresholds. The receipts above cover specific bounded HTTP, browser, saturation,
|
||||
churn, missed-update and process-crash cases, not every combination. The native
|
||||
Windows intermittent Redis read timeout remains unresolved. Loopback HTTP latency must not be reported
|
||||
as browser propagation or a production SLA. No deployed capacity threshold is
|
||||
established by this fixture.
|
||||
@@ -1,145 +0,0 @@
|
||||
// Real UI observations against a caller-owned two-node acceptance fixture.
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { startPresenceLoad } from './browser_presence_load.mjs';
|
||||
|
||||
const [frontendRepo, base, output, countValue = '25', loadMode] = process.argv.slice(2);
|
||||
if (loadMode && loadMode !== 'load') throw new Error('Optional final argument must be load');
|
||||
const samples = Number(countValue);
|
||||
if (!frontendRepo || !base || !output || !Number.isInteger(samples) || samples < 5 || samples > 100) {
|
||||
throw new Error('Expected frontend repo, loopback base URL, result path and 5..100 samples');
|
||||
}
|
||||
if (!['localhost', '127.0.0.1'].includes(new URL(base).hostname)) {
|
||||
throw new Error('Only the disposable loopback fixture is supported');
|
||||
}
|
||||
const require = createRequire(resolve(frontendRepo, 'package.json'));
|
||||
const { chromium, expect } = require('@playwright/test');
|
||||
const result = { status: 'FAIL', samples: [], streams: [], requests: [], errors: [],
|
||||
scope: 'Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load' };
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
let load;
|
||||
try {
|
||||
async function editor(user, node) {
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([
|
||||
{ name: 'collab_dev_user', value: user, url: base },
|
||||
{ name: 'presence_node', value: node, url: base },
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => result.errors.push(error.message));
|
||||
page.on('response', response => {
|
||||
const path = new URL(response.url()).pathname;
|
||||
if (path.endsWith('/realtime/stream')) {
|
||||
result.streams.push({ user, status: response.status(), type: response.headers()['content-type'] });
|
||||
}
|
||||
if (path.includes('/presence/leases')) {
|
||||
result.requests.push({ user, status: response.status(), node: response.headers()['x-acceptance-node'] });
|
||||
}
|
||||
});
|
||||
await page.goto(`${base}/app/person/1/edit?createMockSession=true`);
|
||||
await expect(page.locator('[data-qqq-id="firstName"]')).toBeVisible();
|
||||
return page;
|
||||
}
|
||||
const observer = await editor('alice', 'first');
|
||||
const sender = await editor('admin', 'second');
|
||||
await observer.locator('[data-qqq-id="firstName"]').fill('Retained observer draft');
|
||||
await sender.locator('[data-qqq-id="firstName"]').fill('Retained sender draft');
|
||||
await expect(observer.locator('[data-presence-field="firstName"]')).toContainText('Demo Administrator');
|
||||
if (loadMode) {
|
||||
load = await startPresenceLoad(browser, base, expect);
|
||||
result.load = load.state;
|
||||
result.scope = 'Chromium focus-to-remote-DOM and reconnect under 16 concurrent HTTP lease workers; loopback, four background identities';
|
||||
}
|
||||
for (let index = 0; index < samples; index++) {
|
||||
const previousCompleted = load?.state.completed;
|
||||
if (load) await delay(60000 / samples);
|
||||
const field = index % 2 === 0 ? 'lastName' : 'firstName';
|
||||
const previous = field === 'firstName' ? 'lastName' : 'firstName';
|
||||
await expect(observer.locator(`[data-presence-field="${field}"]`)).not.toContainText('Demo Administrator');
|
||||
const before = performance.now();
|
||||
await sender.locator(`[data-qqq-id="${field}"]`).focus();
|
||||
await expect(observer.locator(`[data-presence-field="${field}"]`)).toContainText('Demo Administrator');
|
||||
result.samples.push({ field, propagation_ms: performance.now() - before });
|
||||
await expect(observer.locator(`[data-presence-field="${previous}"]`)).not.toContainText('Demo Administrator');
|
||||
if (load) {
|
||||
expect(load.state.errors).toEqual([]);
|
||||
expect(load.state.completed).toBeGreaterThan(previousCompleted);
|
||||
}
|
||||
}
|
||||
// Drop a real browser connection, miss multiple focus updates, then converge
|
||||
// through the shipped reconnect/polling behavior without reloading the form.
|
||||
const oldField = samples % 2 === 0 ? 'firstName' : 'lastName';
|
||||
const newField = oldField === 'firstName' ? 'lastName' : 'firstName';
|
||||
const streamsBefore = result.streams.filter(stream => stream.user === 'alice').length;
|
||||
result.reconnect = { status: 'FAIL', missed_changes: 0 };
|
||||
const interrupted = observer.waitForEvent('requestfailed', {
|
||||
predicate: request => new URL(request.url()).pathname.endsWith('/realtime/stream'),
|
||||
timeout: 10000,
|
||||
});
|
||||
await observer.context().setOffline(true);
|
||||
const disconnect = await fetch(`${base}/__acceptance/disconnect-first`, { method: 'POST' });
|
||||
expect(disconnect.status).toBe(200);
|
||||
result.reconnect.disconnected_streams = (await disconnect.json()).disconnected;
|
||||
expect(result.reconnect.disconnected_streams).toBeGreaterThan(0);
|
||||
const failedStream = await interrupted;
|
||||
result.reconnect.stream_failure = failedStream.failure()?.errorText;
|
||||
const offlineFetch = await observer.evaluate(async () => {
|
||||
try {
|
||||
await fetch(`/app/?offlineProbe=${Date.now()}`, { cache: 'no-store' });
|
||||
return false;
|
||||
} catch { return true; }
|
||||
});
|
||||
expect(offlineFetch).toBe(true);
|
||||
for (const field of [newField, oldField, newField]) {
|
||||
const renewed = sender.waitForResponse(response =>
|
||||
new URL(response.url()).pathname.endsWith('/presence/leases/renew') &&
|
||||
response.request().method() === 'POST' && response.status() === 200 &&
|
||||
response.request().postDataJSON()?.fieldName === field);
|
||||
await sender.locator(`[data-qqq-id="${field}"]`).focus();
|
||||
await renewed;
|
||||
result.reconnect.missed_changes++;
|
||||
}
|
||||
await expect(observer.locator(`[data-presence-field="${newField}"]`)).not.toContainText('Demo Administrator');
|
||||
const resumed = performance.now();
|
||||
await observer.context().setOffline(false);
|
||||
await expect.poll(() => result.streams.filter(stream => stream.user === 'alice' &&
|
||||
stream.status === 200 && stream.type?.includes('text/event-stream')).length,
|
||||
{ timeout: 30000 }).toBeGreaterThan(streamsBefore);
|
||||
await expect(observer.locator(`[data-presence-field="${newField}"]`)).toContainText('Demo Administrator', { timeout: 30000 });
|
||||
await expect(observer.locator(`[data-presence-field="${oldField}"]`)).not.toContainText('Demo Administrator');
|
||||
result.reconnect.recovery_ms = performance.now() - resumed;
|
||||
result.reconnect.status = 'PASS';
|
||||
if (load) {
|
||||
await load.stop();
|
||||
expect(load.state.errors).toEqual([]);
|
||||
expect(load.state.elapsed_ms).toBeGreaterThanOrEqual(60000);
|
||||
expect(load.state.nodes.first).toBeGreaterThan(0);
|
||||
expect(load.state.nodes.second).toBeGreaterThan(0);
|
||||
load = undefined;
|
||||
}
|
||||
await expect(observer.locator('[data-qqq-id="firstName"]')).toHaveValue('Retained observer draft');
|
||||
await expect(sender.locator('[data-qqq-id="firstName"]')).toHaveValue('Retained sender draft');
|
||||
for (const user of ['alice', 'admin']) {
|
||||
expect(result.streams.some(stream => stream.user === user && stream.status === 200 && stream.type?.includes('text/event-stream'))).toBe(true);
|
||||
}
|
||||
expect(result.requests.some(request => request.user === 'alice' && request.node === 'first')).toBe(true);
|
||||
expect(result.requests.some(request => request.user === 'admin' && request.node === 'second')).toBe(true);
|
||||
expect(result.requests.every(request => request.status === 200)).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
const ordered = result.samples.map(sample => sample.propagation_ms).sort((a, b) => a - b);
|
||||
result.percentiles_ms = Object.fromEntries([['p50', .5], ['p95', .95], ['p99', .99], ['max', 1]]
|
||||
.map(([name, quantile]) => [name, ordered[Math.ceil(ordered.length * quantile) - 1]]));
|
||||
result.status = 'PASS';
|
||||
} catch (error) {
|
||||
result.failure = String(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (load) await load.stop();
|
||||
await browser.close();
|
||||
await writeFile(output, JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Bounded real HTTP load using caller-owned authenticated browser contexts.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
export async function startPresenceLoad(browser, base, expect) {
|
||||
const state = { workers: 16, completed: 0, errors: [], nodes: {}, elapsed_ms: 0 };
|
||||
const contexts = [];
|
||||
const leases = [];
|
||||
let running = true;
|
||||
let stopped = false;
|
||||
let tasks = [];
|
||||
const began = performance.now();
|
||||
const endpoint = `${base}/qqq/v1/qbit/collaboration/presence/leases`;
|
||||
async function post(context, path, data) {
|
||||
const response = await context.request.post(endpoint + path, { data, timeout: 10000 });
|
||||
try {
|
||||
expect(response.status()).toBe(200);
|
||||
const node = response.headers()['x-acceptance-node'];
|
||||
state.nodes[node] = (state.nodes[node] ?? 0) + 1;
|
||||
return await response.json();
|
||||
} finally { await response.dispose(); }
|
||||
}
|
||||
async function stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
running = false;
|
||||
await Promise.all(tasks);
|
||||
state.elapsed_ms = performance.now() - began;
|
||||
for (const lease of leases) {
|
||||
try {
|
||||
const response = await lease.context.request.delete(`${base}/qqq/v1/qbit/collaboration/presence`, {
|
||||
data: { ...lease.target, connectionId: lease.id, sequence: ++lease.sequence }, timeout: 10000,
|
||||
});
|
||||
try { expect(response.status()).toBe(200); } finally { await response.dispose(); }
|
||||
} catch (error) { state.errors.push(String(error)); }
|
||||
}
|
||||
for (const context of contexts) await context.close();
|
||||
}
|
||||
try {
|
||||
for (const [index, user] of ['bob', 'carol', 'dave', 'erin'].entries()) {
|
||||
const context = await browser.newContext();
|
||||
contexts.push(context);
|
||||
await context.addCookies([
|
||||
{ name: 'collab_dev_user', value: user, url: base },
|
||||
{ name: 'presence_node', value: index % 2 ? 'second' : 'first', url: base },
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${base}/app/person/1?createMockSession=true`);
|
||||
await expect(page.locator('[data-qqq-id="record-view-title-person"]')).toBeVisible();
|
||||
await page.close();
|
||||
for (let tab = 0; tab < 4; tab++) {
|
||||
const target = { table: 'person', pk: '1' };
|
||||
const opened = await post(context, '', target);
|
||||
expect(typeof opened.connectionId).toBe('string');
|
||||
leases.push({ context, target, id: opened.connectionId, sequence: 0, completed: 0 });
|
||||
}
|
||||
}
|
||||
tasks = leases.map(async lease => {
|
||||
try {
|
||||
while (running) {
|
||||
await post(lease.context, '/renew', { ...lease.target, connectionId: lease.id,
|
||||
sequence: ++lease.sequence, mode: 'VIEWING' });
|
||||
lease.completed++;
|
||||
state.completed++;
|
||||
await delay(100);
|
||||
}
|
||||
} catch (error) { state.errors.push(String(error)); running = false; }
|
||||
});
|
||||
await expect.poll(() => leases.filter(lease => lease.completed > 0).length).toBe(16);
|
||||
return { state, stop };
|
||||
} catch (error) {
|
||||
await stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// Verify owning-node routing with real published UI and backend artifacts.
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
const [frontendRepo, base, output] = process.argv.slice(2);
|
||||
if (!frontendRepo || !output || !['localhost', '127.0.0.1'].includes(new URL(base).hostname)) {
|
||||
throw new Error('Expected frontend repo, owned loopback URL and result path');
|
||||
}
|
||||
const require = createRequire(resolve(frontendRepo, 'package.json'));
|
||||
const { chromium, expect } = require('@playwright/test');
|
||||
const result = { status: 'FAIL', checks: [], errors: [] };
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([
|
||||
{ name: 'collab_dev_user', value: 'alice', url: base },
|
||||
{ name: 'presence_node', value: 'first', url: base },
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => result.errors.push(error.message));
|
||||
const subscribed = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname.endsWith('/realtime/subscriptions') && response.status() === 200);
|
||||
await page.goto(`${base}/app/person/1/edit?createMockSession=true`);
|
||||
await expect(page.locator('[data-qqq-id="firstName"]')).toBeVisible();
|
||||
await page.locator('[data-qqq-id="firstName"]').fill('Keep this unsaved draft');
|
||||
const response = await subscribed;
|
||||
expect(response.headers()['x-acceptance-node']).toBe('first');
|
||||
const { connectionId } = response.request().postDataJSON();
|
||||
expect(typeof connectionId).toBe('string');
|
||||
for (const [node, expected] of [['first', 200], ['second', 404], ['first', 200]]) {
|
||||
// Keep the same session/user cookies and change only the test routing cookie.
|
||||
const cookies = (await context.cookies(base)).map(cookie =>
|
||||
`${cookie.name}=${cookie.name === 'presence_node' ? node : cookie.value}`).join('; ');
|
||||
const changed = await context.request.post(`${base}/qqq/v1/realtime/subscriptions`, {
|
||||
headers: { Cookie: cookies },
|
||||
data: { connectionId, add: ['record:person:1'], remove: [] }, timeout: 10000,
|
||||
});
|
||||
try {
|
||||
result.checks.push({ node: changed.headers()['x-acceptance-node'], status: changed.status() });
|
||||
expect(changed.headers()['x-acceptance-node']).toBe(node);
|
||||
expect(changed.status()).toBe(expected);
|
||||
if (expected === 200) {
|
||||
const body = await changed.json();
|
||||
expect(body.active).toContain('record:person:1');
|
||||
expect(body.refused ?? []).toEqual([]);
|
||||
}
|
||||
} finally { await changed.dispose(); }
|
||||
}
|
||||
await expect(page.locator('[data-qqq-id="firstName"]')).toHaveValue('Keep this unsaved draft');
|
||||
expect(result.errors).toEqual([]);
|
||||
result.status = 'PASS';
|
||||
} catch (error) {
|
||||
result.failure = String(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await writeFile(output, JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
"""Bounded HTTP presence workload across independent, disposable JVMs.
|
||||
|
||||
This measures loopback HTTP service latency, not browser propagation or a
|
||||
production capacity limit. Run test-compile and dependency:build-classpath with
|
||||
test scope and output target/capacity-classpath.txt before invoking this script.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
|
||||
def port():
|
||||
"""Choose a loopback port; child startup detects a competing bind."""
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
def request(node, user, path, body=None, tab=0, method=None, expected=200):
|
||||
"""Use real HTTP and the explicit mock identity; no tenant parameter."""
|
||||
separator = "&" if "?" in path else "?"
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{node}/qqq/v1/qbit/collaboration{path}{separator}user={user}",
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json",
|
||||
"Cookie": f"sessionUUID={user}-capacity-{tab}"})
|
||||
try:
|
||||
response = urllib.request.urlopen(req, timeout=10)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == expected:
|
||||
with error:
|
||||
return json.load(error)
|
||||
with error:
|
||||
detail = error.read(4096).decode(errors="replace")
|
||||
raise RuntimeError(f"HTTP {error.code} node={node} user={user} path={path}: {detail}") from error
|
||||
with response:
|
||||
if response.status != expected:
|
||||
raise AssertionError(f"Unexpected status {response.status}")
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def distribution(values):
|
||||
"""Nearest-rank milliseconds, with no discarded slow or failed samples."""
|
||||
ordered = sorted(values)
|
||||
return {"samples": len(ordered), **{
|
||||
name: ordered[max(0, math.ceil(len(ordered) * fraction) - 1)]
|
||||
for name, fraction in (("p50_ms", .5), ("p95_ms", .95), ("p99_ms", .99), ("max_ms", 1))}}
|
||||
|
||||
|
||||
def main():
|
||||
"""Own child JVMs/container and retain results even when a check fails."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java", required=True)
|
||||
parser.add_argument("--node-image", help="Optional Linux Java21 image; isolate Redis traffic on an owned Docker network")
|
||||
parser.add_argument("--nodes", type=int, default=3)
|
||||
parser.add_argument("--tenants", type=int, default=2)
|
||||
parser.add_argument("--users", type=int, default=6)
|
||||
parser.add_argument("--tabs", type=int, default=4)
|
||||
parser.add_argument("--rounds", type=int, default=10)
|
||||
parser.add_argument("--workers", type=int, default=8)
|
||||
parser.add_argument("--crash-round", type=int, default=0, help="Kill the first node before this round; zero disables")
|
||||
parser.add_argument("--churn-every", type=int, default=0, help="Leave/rejoin the last user's tabs every N rounds; zero disables")
|
||||
parser.add_argument("--pause-redis", action="store_true", help="After the workload, freeze owned Redis and verify bounded failures/recovery")
|
||||
args = parser.parse_args()
|
||||
for name, lower, upper in (("nodes", 2, 8), ("tenants", 2, 8), ("users", 2, 100),
|
||||
("tabs", 2, 8), ("rounds", 1, 1000), ("workers", 1, 64)):
|
||||
if not lower <= getattr(args, name) <= upper:
|
||||
parser.error(f"{name} must be {lower}..{upper}")
|
||||
if args.crash_round and not 2 <= args.crash_round <= args.rounds:
|
||||
parser.error("crash-round must be zero or 2..rounds")
|
||||
if args.churn_every and not 1 <= args.churn_every <= args.rounds:
|
||||
parser.error("churn-every must be zero or 1..rounds")
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
output = repo / "target" / ("capacity-nodes-" + uuid.uuid4().hex[:10])
|
||||
output.mkdir(parents=True)
|
||||
classpath = os.pathsep.join([str(repo / "target/test-classes"), str(repo / "target/classes"),
|
||||
(repo / "target/capacity-classpath.txt").read_text().strip()])
|
||||
processes, logs = [], []
|
||||
container = "qqq-capacity-" + uuid.uuid4().hex[:12]
|
||||
network = container + "-net"
|
||||
node_containers = []
|
||||
network_created = False
|
||||
result = {"status": "FAIL", "parameters": vars(args), "scope": "loopback HTTP, mock identities, memory records"}
|
||||
try:
|
||||
network_args = []
|
||||
if args.node_image:
|
||||
result["node_image_id"] = subprocess.check_output(
|
||||
["docker", "image", "inspect", args.node_image, "--format", "{{.Id}}"], text=True).strip()
|
||||
subprocess.run(["docker", "network", "create", network], check=True, capture_output=True)
|
||||
network_created = True
|
||||
network_args = ["--network", network]
|
||||
dependencies = output / "dependencies"
|
||||
dependencies.mkdir()
|
||||
linux_classpath = ["/fixture/target/test-classes", "/fixture/target/classes"]
|
||||
for index, dependency in enumerate((repo / "target/capacity-classpath.txt").read_text().strip().split(os.pathsep)):
|
||||
destination = dependencies / f"{index}.jar"
|
||||
shutil.copyfile(dependency, destination)
|
||||
linux_classpath.append("/fixture/" + destination.relative_to(repo).as_posix())
|
||||
classpath = ":".join(linux_classpath)
|
||||
subprocess.run(["docker", "run", "-d", "--name", container, "-p", "127.0.0.1::6379",
|
||||
*network_args,
|
||||
"redis:7.4-alpine", "redis-server", "--slowlog-log-slower-than", "1000",
|
||||
"--slowlog-max-len", "128", "--latency-monitor-threshold", "1"], check=True, capture_output=True)
|
||||
redis_port = int(subprocess.check_output(["docker", "port", container, "6379"], text=True).strip().rsplit(":", 1)[1])
|
||||
nodes = [port() for _ in range(args.nodes)]
|
||||
if len(set(nodes)) != len(nodes):
|
||||
raise RuntimeError("Port allocation collision; no nodes started")
|
||||
for node in nodes:
|
||||
argfile = output / f"node-{node}.args"
|
||||
connection_properties = (f"-Dcollab.capacity.redisHost={container}\n-Dcollab.dev.bindAddress=0.0.0.0\n"
|
||||
if args.node_image else "")
|
||||
argfile.write_text(f'-Xmx384m\n-Dcollab.capacity.tenants={args.tenants}\n-Dcollab.capacity.users={args.users}\n'
|
||||
+ connection_properties +
|
||||
'-cp\n"' + classpath.replace("\\", "/") + '"\n'
|
||||
'com.kingsrook.qbits.collaboration.devserver.CapacityPresenceDevServer\n'
|
||||
+ (f'8080\n6379\n' if args.node_image else f'{node}\n{redis_port}\n'), encoding="utf-8")
|
||||
log = (output / f"node-{node}.log").open("w")
|
||||
logs.append(log)
|
||||
command = [args.java, "@" + str(argfile)]
|
||||
if args.node_image:
|
||||
node_container = f"{container}-node-{node}"
|
||||
node_containers.append(node_container)
|
||||
command = ["docker", "run", "--rm", "--name", node_container, "--network", network,
|
||||
"-p", f"127.0.0.1:{node}:8080", "--mount", f"type=bind,source={repo},target=/fixture,readonly",
|
||||
args.node_image, "java", "@/fixture/" + argfile.relative_to(repo).as_posix()]
|
||||
processes.append(subprocess.Popen(command, cwd=repo,
|
||||
stdout=log, stderr=subprocess.STDOUT,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)))
|
||||
deadline = time.monotonic() + 120
|
||||
for node, process in zip(nodes, processes):
|
||||
while "CAPACITY_PRESENCE_READY " not in (output / f"node-{node}.log").read_text(errors="replace"):
|
||||
if process.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError(f"Node {node} failed startup; inspect {output}")
|
||||
time.sleep(.2)
|
||||
actors = []
|
||||
for tenant in range(args.tenants):
|
||||
for user in range(args.users):
|
||||
for tab in range(args.tabs):
|
||||
identity = f"load-t{tenant}-u{user}"
|
||||
record = str(1 + tab % 2)
|
||||
node = nodes[len(actors) % len(nodes)]
|
||||
lease = request(node, identity, "/presence/leases", {"table": "person", "pk": record}, tab)["connectionId"]
|
||||
actors.append((identity, record, tab, lease, user))
|
||||
renewals, snapshots = [], []
|
||||
|
||||
def check_rooms(excluded=None):
|
||||
"""Check every tenant/record on every surviving node, including absence."""
|
||||
for tenant in range(args.tenants):
|
||||
expected_users = {f"load-t{tenant}-u{user}" for user in range(args.users) if user != excluded}
|
||||
for node in nodes:
|
||||
for record in ("1", "2"):
|
||||
before = time.perf_counter()
|
||||
viewers = request(node, f"load-t{tenant}-u0", f"/presence?table=person&pk={record}")["viewers"]
|
||||
snapshots.append((time.perf_counter() - before) * 1000)
|
||||
actual = {viewer["userId"] for viewer in viewers}
|
||||
if actual != expected_users or len(viewers) != len(expected_users):
|
||||
raise AssertionError(f"Tenant isolation/deduplication mismatch: {actual} != {expected_users}")
|
||||
if sum(viewer["mode"] == "EDITING" for viewer in viewers) != sum(user != excluded for user in range(2)):
|
||||
raise AssertionError("Unexpected editor count")
|
||||
|
||||
def renew(item, sequence):
|
||||
"""Keep each tab sequential while moving its writes between nodes."""
|
||||
index, (identity, record, tab, lease, user) = item
|
||||
before = time.perf_counter()
|
||||
request(nodes[(index + sequence) % len(nodes)], identity, "/presence/leases/renew",
|
||||
{"connectionId": lease, "table": "person", "pk": record,
|
||||
"mode": "EDITING" if user < 2 else "VIEWING", "sequence": sequence}, tab)
|
||||
return (time.perf_counter() - before) * 1000
|
||||
|
||||
started = time.perf_counter()
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as workers:
|
||||
for sequence in range(1, args.rounds + 1):
|
||||
result["current_round"] = sequence
|
||||
if sequence == args.crash_round:
|
||||
crashed = nodes.pop(0)
|
||||
if args.node_image:
|
||||
subprocess.run(["docker", "kill", node_containers[0]], check=True, capture_output=True)
|
||||
else:
|
||||
processes[0].kill()
|
||||
processes[0].wait(timeout=10)
|
||||
result["crash"] = {"round": sequence, "http_port": crashed,
|
||||
"surviving_nodes": len(nodes), "process_exit": processes[0].returncode}
|
||||
renewals.extend(workers.map(lambda item: renew(item, sequence), enumerate(actors)))
|
||||
check_rooms()
|
||||
if args.churn_every and sequence % args.churn_every == 0:
|
||||
departing = [(index, actor) for index, actor in enumerate(actors) if actor[4] == args.users - 1]
|
||||
for index, (identity, record, tab, lease, user) in departing:
|
||||
request(nodes[index % len(nodes)], identity, "/presence",
|
||||
{"connectionId": lease, "table": "person", "pk": record, "sequence": sequence + 1}, tab, method="DELETE")
|
||||
response = request(nodes[(index + 1) % len(nodes)], identity, "/presence/leases/renew",
|
||||
{"connectionId": lease, "table": "person", "pk": record,
|
||||
"mode": "VIEWING", "sequence": sequence + 2}, tab, expected=409)
|
||||
if response.get("code") != "PRESENCE_LEASE_EXPIRED":
|
||||
raise AssertionError("Departed lease was not rejected as expired")
|
||||
check_rooms(excluded=args.users - 1)
|
||||
for index, (identity, record, tab, lease, user) in departing:
|
||||
replacement = request(nodes[(index + 1) % len(nodes)], identity, "/presence/leases",
|
||||
{"table": "person", "pk": record}, tab)["connectionId"]
|
||||
if replacement == lease:
|
||||
raise AssertionError("Rejoin reused departed authority")
|
||||
actors[index] = (identity, record, tab, replacement, user)
|
||||
renew((index, actors[index]), sequence)
|
||||
check_rooms()
|
||||
result["churn_cycles"] = result.get("churn_cycles", 0) + 1
|
||||
result["departed_leases_rejected"] = result.get("departed_leases_rejected", 0) + len(departing)
|
||||
result["completed_rounds"] = sequence
|
||||
if args.pause_redis:
|
||||
failures = []
|
||||
|
||||
def stalled_renew(item):
|
||||
"""A frozen authority must fail explicitly, never acknowledge a write."""
|
||||
index, (identity, record, tab, lease, user) = item
|
||||
before = time.perf_counter()
|
||||
response = request(nodes[index % len(nodes)], identity, "/presence/leases/renew",
|
||||
{"connectionId": lease, "table": "person", "pk": record,
|
||||
"mode": "EDITING" if user < 2 else "VIEWING", "sequence": args.rounds + 1},
|
||||
tab, expected=503)
|
||||
if "viewers" in response or "temporarily unavailable" not in response.get("message", ""):
|
||||
raise AssertionError("Stalled authority did not return the expected unavailable response")
|
||||
duration = (time.perf_counter() - before) * 1000
|
||||
if duration > 8000:
|
||||
raise AssertionError("Unavailable response exceeded the eight-second fixture bound")
|
||||
return duration
|
||||
|
||||
subprocess.run(["docker", "pause", container], check=True, capture_output=True)
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=64) as burst:
|
||||
failures.extend(burst.map(stalled_renew, enumerate(actors)))
|
||||
for node in nodes:
|
||||
response = request(node, "load-t0-u0", "/presence?table=person&pk=1", expected=503)
|
||||
if "viewers" in response:
|
||||
raise AssertionError("Stalled authority was presented as a room snapshot")
|
||||
finally:
|
||||
subprocess.run(["docker", "unpause", container], check=True, capture_output=True)
|
||||
subprocess.run(["docker", "exec", container, "redis-cli", "PING"], check=True, capture_output=True, timeout=10)
|
||||
for item in enumerate(actors):
|
||||
renew(item, args.rounds + 2)
|
||||
check_rooms()
|
||||
result["redis_stall"] = {"rejected_renewals": len(failures), "failed_snapshot_nodes": len(nodes),
|
||||
"response_times": distribution(failures), "same_leases_recovered": True}
|
||||
elapsed = time.perf_counter() - started
|
||||
result.update(status="PASS", seconds=elapsed, renewals=distribution(renewals),
|
||||
snapshots=distribution(snapshots), renewal_throughput=len(renewals) / elapsed,
|
||||
node_pids=[process.pid for process in processes],
|
||||
pid_kind="docker-client" if args.node_image else "JVM", node_containers=node_containers)
|
||||
except Exception as error:
|
||||
result["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
failure_active = sys.exc_info()[0] is not None
|
||||
try:
|
||||
slow = json.loads(subprocess.check_output(
|
||||
["docker", "exec", container, "redis-cli", "--json", "SLOWLOG", "GET", "128"], text=True, timeout=10))
|
||||
result["redis_slow_commands"] = [{"unix_time": entry[1], "duration_us": entry[2], "command": entry[3][0]}
|
||||
for entry in slow]
|
||||
result["redis_latency_events"] = json.loads(subprocess.check_output(
|
||||
["docker", "exec", container, "redis-cli", "--json", "LATENCY", "LATEST"], text=True, timeout=10))
|
||||
for section in ("cpu", "memory", "commandstats", "stats"):
|
||||
value = subprocess.check_output(["docker", "exec", container, "redis-cli", "INFO", section], text=True, timeout=10)
|
||||
(output / f"redis-{section}.txt").write_text(value)
|
||||
parsed = dict(line.split(":", 1) for line in value.splitlines() if ":" in line and not line.startswith("#"))
|
||||
if section == "memory":
|
||||
result["redis_memory"] = {key: int(parsed[key]) for key in
|
||||
("used_memory", "used_memory_peak", "used_memory_rss")}
|
||||
if section == "cpu":
|
||||
result["redis_cpu_seconds_since_start"] = {key: float(parsed[key]) for key in
|
||||
("used_cpu_sys", "used_cpu_user")}
|
||||
except (subprocess.SubprocessError, ValueError, KeyError) as error:
|
||||
result["redis_diagnostic_error"] = type(error).__name__
|
||||
node_cleanup = True
|
||||
for node_container in node_containers:
|
||||
removed = subprocess.run(["docker", "rm", "-f", node_container], capture_output=True)
|
||||
if removed.returncode != 0:
|
||||
remaining = subprocess.run(["docker", "ps", "-aq", "--filter", f"name=^{node_container}$"], capture_output=True, text=True)
|
||||
node_cleanup = node_cleanup and remaining.returncode == 0 and not remaining.stdout.strip()
|
||||
for process in processes:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
for log in logs:
|
||||
log.close()
|
||||
resources = []
|
||||
for node_log in sorted(output.glob("node-*.log")):
|
||||
samples = [json.loads(line.split("CAPACITY_METRIC ", 1)[1])
|
||||
for line in node_log.read_text(errors="replace").splitlines()
|
||||
if line.startswith("CAPACITY_METRIC ")]
|
||||
if samples:
|
||||
first, last = samples[0], samples[-1]
|
||||
wall_ms = last["uptimeMillis"] - first["uptimeMillis"]
|
||||
cpu_ns = last["cpuNanos"] - first["cpuNanos"]
|
||||
resources.append({"node_log": node_log.name, "samples": len(samples),
|
||||
"observed_wall_ms": wall_ms,
|
||||
"cpu_seconds": cpu_ns / 1e9 if first["cpuNanos"] >= 0 else None,
|
||||
"cpu_core_equivalents": cpu_ns / (wall_ms * 1e6) if wall_ms > 0 and first["cpuNanos"] >= 0 else None,
|
||||
**{key + "_sampled_max": max(sample[key] for sample in samples)
|
||||
for key in ("heapUsedBytes", "heapCommittedBytes", "poolActive", "poolIdle", "poolWaiting", "poolMaximum")},
|
||||
"residentBytes_sampled_max": max(sample["residentBytes"] for sample in samples)
|
||||
if all(sample.get("residentBytes", -1) >= 0 for sample in samples) else None})
|
||||
if any(sample["poolActive"] > sample["poolMaximum"] for sample in samples):
|
||||
result.update(status="FAIL", error="Sampled pool active count exceeded configured maximum")
|
||||
result["resources"] = resources
|
||||
if result["status"] == "PASS" and args.node_image and any(node["residentBytes_sampled_max"] is None for node in resources):
|
||||
result.update(status="FAIL", error="Linux resident-memory samples missing")
|
||||
if result["status"] == "PASS" and any(key not in result for key in ("redis_memory", "redis_cpu_seconds_since_start")):
|
||||
result.update(status="FAIL", error="Redis memory/CPU diagnostics missing")
|
||||
if result["status"] == "PASS" and len(resources) != args.nodes:
|
||||
result.update(status="FAIL", error="Missing resource samples for a node")
|
||||
if result["status"] == "PASS" and args.pause_redis and not any(node["poolWaiting_sampled_max"] > 0 for node in resources):
|
||||
result.update(status="FAIL", error="Redis stall did not demonstrate pool waiters")
|
||||
cleanup = subprocess.run(["docker", "rm", "-f", container], capture_output=True)
|
||||
network_cleanup = True
|
||||
if network_created:
|
||||
network_cleanup = subprocess.run(["docker", "network", "rm", network], capture_output=True).returncode == 0
|
||||
result["cleanup"] = cleanup.returncode == 0 and node_cleanup and network_cleanup and all(process.poll() is not None for process in processes)
|
||||
(output / "result.json").write_text(json.dumps(result, indent=2))
|
||||
print(json.dumps(result, indent=2))
|
||||
print(output)
|
||||
if result["status"] == "PASS" and not result["cleanup"]:
|
||||
raise RuntimeError("Workload passed but owned resource cleanup failed")
|
||||
if result["status"] == "FAIL" and not failure_active:
|
||||
raise RuntimeError(result.get("error", "Capacity resource validation failed"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,42 +0,0 @@
|
||||
// Disposable loopback fixture only; never deploy this fault-injection router.
|
||||
import http from 'node:http';
|
||||
|
||||
const [first, second, listen] = process.argv.slice(2).map(Number);
|
||||
if (![first, second, listen].every(port => Number.isInteger(port) && port > 0 && port < 65536)) {
|
||||
throw new Error('Expected first-node, second-node and router ports');
|
||||
}
|
||||
const firstStreams = new Set();
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.method === 'POST' && request.url === '/__acceptance/disconnect-first') {
|
||||
const disconnected = firstStreams.size;
|
||||
for (const stream of firstStreams) stream.destroy();
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ disconnected }));
|
||||
return;
|
||||
}
|
||||
const isSecond = /(?:^|;\s*)presence_node=second(?:;|$)/.test(request.headers.cookie ?? '');
|
||||
const port = isSecond ? second : first;
|
||||
const upstream = http.request({
|
||||
hostname: '127.0.0.1', port, path: request.url, method: request.method,
|
||||
headers: { ...request.headers, host: `127.0.0.1:${port}` },
|
||||
}, incoming => {
|
||||
response.writeHead(incoming.statusCode, {
|
||||
...incoming.headers, 'x-acceptance-node': isSecond ? 'second' : 'first',
|
||||
});
|
||||
if (!isSecond && incoming.headers['content-type']?.includes('text/event-stream')) {
|
||||
firstStreams.add(response);
|
||||
}
|
||||
incoming.pipe(response);
|
||||
});
|
||||
upstream.on('error', () => {
|
||||
if (!response.headersSent) response.writeHead(502);
|
||||
response.end('Acceptance node unavailable');
|
||||
});
|
||||
response.on('close', () => {
|
||||
firstStreams.delete(response);
|
||||
upstream.destroy();
|
||||
});
|
||||
request.pipe(upstream);
|
||||
});
|
||||
server.listen(listen, '127.0.0.1', () => console.log(`PRESENCE_ROUTER_READY ${listen}`));
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close());
|
||||
@@ -1,285 +0,0 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"samples": [
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 36.092099999999846
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.51870000000008
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.758600000000115
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.090299999999843
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.64980000000014
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.648199999999633
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.395500000000084
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.36750000000029
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.601799999999912
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.90599999999995
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.44150000000036
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.186699999999746
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.115800000000036
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.539400000000114
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.188000000000102
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.021200000000135
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 33.07759999999962
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.7176000000004
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.457300000000032
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.920900000000074
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.89130000000023
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.847200000000157
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 30.158300000000054
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.48239999999987
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.337600000000293
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"scope": "Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load",
|
||||
"percentiles_ms": {
|
||||
"p50": 31.648199999999633,
|
||||
"p95": 33.07759999999962,
|
||||
"p99": 36.092099999999846,
|
||||
"max": 36.092099999999846
|
||||
},
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"fixture_cleanup": true,
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records."
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"samples": [
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 36.560600000000704
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.44050000000061
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.50890000000072
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 785.4951000000001
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 85.39069999999992
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.589599999999336
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.756099999998696
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 38.17399999999907
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 30.243099999999686
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.600799999992887
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 29.57220000000234
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.197800000001735
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 33.05130000000645
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.38390000000072
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.52850000000035
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 34.66169999999693
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.258300000001327
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 44.19029999999475
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 36.56189999999333
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.240600000004633
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 29.79899999999907
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.621500000001106
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.66189999999915
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.198399999993853
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.931200000006356
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"scope": "Chromium focus-to-remote-DOM and reconnect under 16 concurrent HTTP lease workers; loopback, four background identities",
|
||||
"load": {
|
||||
"workers": 16,
|
||||
"completed": 9046,
|
||||
"errors": [],
|
||||
"nodes": {
|
||||
"first": 4540,
|
||||
"second": 4522
|
||||
},
|
||||
"elapsed_ms": 69728.3579
|
||||
},
|
||||
"reconnect": {
|
||||
"status": "PASS",
|
||||
"missed_changes": 3,
|
||||
"disconnected_streams": 1,
|
||||
"stream_failure": "net::ERR_INCOMPLETE_CHUNKED_ENCODING",
|
||||
"recovery_ms": 1855.8868999999977
|
||||
},
|
||||
"percentiles_ms": {
|
||||
"p50": 32.38390000000072,
|
||||
"p95": 85.39069999999992,
|
||||
"p99": 785.4951000000001,
|
||||
"max": 785.4951000000001
|
||||
},
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"fixture_cleanup": true,
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
|
||||
"fixture_checks": [
|
||||
"browser-six-presence",
|
||||
"browser-redis-admin",
|
||||
"browser-presence-latency"
|
||||
]
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"samples": [
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 37.25770000000011
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.14499999999998
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.40950000000021
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.52500000000009
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.228200000000015
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.430999999999585
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.493199999999888
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.14539999999988
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.11259999999993
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.23129999999992
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.06800000000021
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.109199999999873
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.534299999999803
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.770800000000236
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.654199999999946
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.31700000000001
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 33.108600000000024
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.27370000000019
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.452499999999873
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.86220000000003
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.734399999999823
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.04489999999987
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.051899999999932
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.876300000000356
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.56970000000001
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"scope": "Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load",
|
||||
"reconnect": {
|
||||
"status": "PASS",
|
||||
"missed_changes": 3,
|
||||
"disconnected_streams": 1,
|
||||
"stream_failure": "net::ERR_INCOMPLETE_CHUNKED_ENCODING",
|
||||
"recovery_ms": 1858.2972000000004
|
||||
},
|
||||
"percentiles_ms": {
|
||||
"p50": 31.86220000000003,
|
||||
"p95": 33.770800000000236,
|
||||
"p99": 37.25770000000011,
|
||||
"max": 37.25770000000011
|
||||
},
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"fixture_cleanup": true,
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
|
||||
"fixture_checks": [
|
||||
"browser-six-presence",
|
||||
"browser-redis-admin",
|
||||
"browser-presence-latency"
|
||||
]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"checks": [
|
||||
{ "node": "first", "status": 200 },
|
||||
{ "node": "second", "status": 404 },
|
||||
{ "node": "first", "status": 200 }
|
||||
],
|
||||
"errors": [],
|
||||
"fixture_cleanup": true,
|
||||
"fixture_checks": ["browser-six-presence", "browser-redis-admin", "browser-subscription-affinity"],
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
|
||||
"scope": "Same browser session and live SSE connection ID; change only test-router affinity. Not deployed Traefik qualification."
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "/path/to/java21",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 50,
|
||||
"tabs": 4,
|
||||
"rounds": 200,
|
||||
"workers": 16,
|
||||
"crash_round": 0,
|
||||
"churn_every": 0,
|
||||
"pause_redis": false
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 200,
|
||||
"completed_rounds": 200,
|
||||
"seconds": 102.56801009993069,
|
||||
"renewals": {
|
||||
"samples": 80000,
|
||||
"p50_ms": 11.325599974952638,
|
||||
"p95_ms": 21.764500066637993,
|
||||
"p99_ms": 25.881099980324507,
|
||||
"max_ms": 1007.4040000326931
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 2400,
|
||||
"p50_ms": 15.130399959161878,
|
||||
"p95_ms": 16.613700077868998,
|
||||
"p99_ms": 19.423600053414702,
|
||||
"max_ms": 28.864299994893372
|
||||
},
|
||||
"renewal_throughput": 779.9702843221492,
|
||||
"node_pids": [
|
||||
105696,
|
||||
4828,
|
||||
117588
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-f58f3ddcf5f3-node-59675",
|
||||
"qqq-capacity-f58f3ddcf5f3-node-59676",
|
||||
"qqq-capacity-f58f3ddcf5f3-node-59677"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793033347,
|
||||
"duration_us": 1432,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033347,
|
||||
"duration_us": 1612,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033340,
|
||||
"duration_us": 1877,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033340,
|
||||
"duration_us": 1313,
|
||||
"command": "ZRANGE"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033336,
|
||||
"duration_us": 1316,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033305,
|
||||
"duration_us": 1459,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033300,
|
||||
"duration_us": 1767,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033290,
|
||||
"duration_us": 1180,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033283,
|
||||
"duration_us": 1421,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033260,
|
||||
"duration_us": 1291,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033260,
|
||||
"duration_us": 1551,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033260,
|
||||
"duration_us": 1083,
|
||||
"command": "ZRANGE"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033255,
|
||||
"duration_us": 1039,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033254,
|
||||
"duration_us": 1215,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793033347,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
"redis_cpu_seconds_since_start": {
|
||||
"used_cpu_sys": 23.737706,
|
||||
"used_cpu_user": 35.526214
|
||||
},
|
||||
"redis_memory": {
|
||||
"used_memory": 2415952,
|
||||
"used_memory_peak": 2579896,
|
||||
"used_memory_rss": 8777728
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-59675.log",
|
||||
"samples": 1112,
|
||||
"observed_wall_ms": 111099,
|
||||
"cpu_seconds": 203.14,
|
||||
"cpu_core_equivalents": 1.828459302063925,
|
||||
"heapUsedBytes_sampled_max": 181098896,
|
||||
"heapCommittedBytes_sampled_max": 211812352,
|
||||
"poolActive_sampled_max": 7,
|
||||
"poolIdle_sampled_max": 7,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8,
|
||||
"residentBytes_sampled_max": 662786048
|
||||
},
|
||||
{
|
||||
"node_log": "node-59676.log",
|
||||
"samples": 1145,
|
||||
"observed_wall_ms": 114399,
|
||||
"cpu_seconds": 200.44,
|
||||
"cpu_core_equivalents": 1.7521132177728826,
|
||||
"heapUsedBytes_sampled_max": 185167416,
|
||||
"heapCommittedBytes_sampled_max": 219152384,
|
||||
"poolActive_sampled_max": 7,
|
||||
"poolIdle_sampled_max": 7,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8,
|
||||
"residentBytes_sampled_max": 662188032
|
||||
},
|
||||
{
|
||||
"node_log": "node-59677.log",
|
||||
"samples": 1159,
|
||||
"observed_wall_ms": 115800,
|
||||
"cpu_seconds": 200.16,
|
||||
"cpu_core_equivalents": 1.728497409326425,
|
||||
"heapUsedBytes_sampled_max": 179378632,
|
||||
"heapCommittedBytes_sampled_max": 210763776,
|
||||
"poolActive_sampled_max": 6,
|
||||
"poolIdle_sampled_max": 7,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8,
|
||||
"residentBytes_sampled_max": 703479808
|
||||
}
|
||||
],
|
||||
"cleanup": true
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin:21-jre",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 1000,
|
||||
"workers": 8
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"current_round": 1000,
|
||||
"completed_rounds": 1000,
|
||||
"seconds": 250.81072249996942,
|
||||
"renewals": {
|
||||
"samples": 96000,
|
||||
"p50_ms": 6.353199947625399,
|
||||
"p95_ms": 15.330899972468615,
|
||||
"p99_ms": 17.760399961844087,
|
||||
"max_ms": 326.8311999272555
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 12000,
|
||||
"p50_ms": 13.105699908919632,
|
||||
"p95_ms": 14.261199976317585,
|
||||
"p99_ms": 16.519399941898882,
|
||||
"max_ms": 915.3468000004068
|
||||
},
|
||||
"renewal_throughput": 382.7587554595546,
|
||||
"node_pids": [
|
||||
64000,
|
||||
40240,
|
||||
115400
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-0a8b7956499d-node-64951",
|
||||
"qqq-capacity-0a8b7956499d-node-64952",
|
||||
"qqq-capacity-0a8b7956499d-node-64953"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793030517,
|
||||
"duration_us": 1117,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793030517,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-64951.log",
|
||||
"samples": 2580,
|
||||
"observed_wall_ms": 257899,
|
||||
"cpu_seconds": 165.55,
|
||||
"cpu_core_equivalents": 0.6419179601316795,
|
||||
"heapUsedBytes_sampled_max": 166667368,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-64952.log",
|
||||
"samples": 2569,
|
||||
"observed_wall_ms": 256799,
|
||||
"cpu_seconds": 165.85,
|
||||
"cpu_core_equivalents": 0.6458358482704372,
|
||||
"heapUsedBytes_sampled_max": 169216944,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-64953.log",
|
||||
"samples": 2608,
|
||||
"observed_wall_ms": 260700,
|
||||
"cpu_seconds": 164.72,
|
||||
"cpu_core_equivalents": 0.631837360951285,
|
||||
"heapUsedBytes_sampled_max": 166319008,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 100,
|
||||
"workers": 8,
|
||||
"crash_round": 50,
|
||||
"churn_every": 20
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 100,
|
||||
"completed_rounds": 100,
|
||||
"churn_cycles": 5,
|
||||
"departed_leases_rejected": 40,
|
||||
"crash": {
|
||||
"round": 50,
|
||||
"http_port": 52824,
|
||||
"surviving_nodes": 2,
|
||||
"process_exit": 137
|
||||
},
|
||||
"seconds": 28.090077199973166,
|
||||
"renewals": {
|
||||
"samples": 9600,
|
||||
"p50_ms": 7.532799965701997,
|
||||
"p95_ms": 17.53149996511638,
|
||||
"p99_ms": 29.359900043345988,
|
||||
"max_ms": 88.13189994543791
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 1092,
|
||||
"p50_ms": 13.582199928350747,
|
||||
"p95_ms": 15.386099927127361,
|
||||
"p99_ms": 18.502700026147068,
|
||||
"max_ms": 22.144700051285326
|
||||
},
|
||||
"renewal_throughput": 341.75769371004685,
|
||||
"node_pids": [
|
||||
22844,
|
||||
66652,
|
||||
61920
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-bb9ba87919d2-node-52824",
|
||||
"qqq-capacity-bb9ba87919d2-node-52825",
|
||||
"qqq-capacity-bb9ba87919d2-node-52827"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793031681,
|
||||
"duration_us": 1206,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793031681,
|
||||
"duration_us": 1147,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793031681,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-52824.log",
|
||||
"samples": 212,
|
||||
"observed_wall_ms": 21099,
|
||||
"cpu_seconds": 29.48,
|
||||
"cpu_core_equivalents": 1.3972226171856486,
|
||||
"heapUsedBytes_sampled_max": 163641024,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 3,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-52825.log",
|
||||
"samples": 354,
|
||||
"observed_wall_ms": 35299,
|
||||
"cpu_seconds": 43.09,
|
||||
"cpu_core_equivalents": 1.22071446783195,
|
||||
"heapUsedBytes_sampled_max": 168178592,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-52827.log",
|
||||
"samples": 340,
|
||||
"observed_wall_ms": 33900,
|
||||
"cpu_seconds": 42.42,
|
||||
"cpu_core_equivalents": 1.2513274336283187,
|
||||
"heapUsedBytes_sampled_max": 166291160,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 100,
|
||||
"workers": 8,
|
||||
"crash_round": 50
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 100,
|
||||
"completed_rounds": 100,
|
||||
"crash": {
|
||||
"round": 50,
|
||||
"http_port": 54039,
|
||||
"surviving_nodes": 2,
|
||||
"process_exit": 137
|
||||
},
|
||||
"seconds": 25.147547999978997,
|
||||
"renewals": {
|
||||
"samples": 9600,
|
||||
"p50_ms": 7.125200005248189,
|
||||
"p95_ms": 17.407900071702898,
|
||||
"p99_ms": 27.217599912546575,
|
||||
"max_ms": 74.53640003222972
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 996,
|
||||
"p50_ms": 13.538200058974326,
|
||||
"p95_ms": 15.59239998459816,
|
||||
"p99_ms": 20.58999997097999,
|
||||
"max_ms": 315.0679999962449
|
||||
},
|
||||
"renewal_throughput": 381.7469599822622,
|
||||
"node_pids": [
|
||||
85832,
|
||||
90280,
|
||||
67764
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-e599dca8e163-node-54039",
|
||||
"qqq-capacity-e599dca8e163-node-54040",
|
||||
"qqq-capacity-e599dca8e163-node-54041"
|
||||
],
|
||||
"redis_slow_commands": [],
|
||||
"redis_latency_events": [],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-54039.log",
|
||||
"samples": 198,
|
||||
"observed_wall_ms": 19699,
|
||||
"cpu_seconds": 28.58,
|
||||
"cpu_core_equivalents": 1.4508350677699375,
|
||||
"heapUsedBytes_sampled_max": 163478640,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 3,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-54040.log",
|
||||
"samples": 300,
|
||||
"observed_wall_ms": 29900,
|
||||
"cpu_seconds": 41.85,
|
||||
"cpu_core_equivalents": 1.399665551839465,
|
||||
"heapUsedBytes_sampled_max": 165177008,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-54041.log",
|
||||
"samples": 331,
|
||||
"observed_wall_ms": 33000,
|
||||
"cpu_seconds": 41.52,
|
||||
"cpu_core_equivalents": 1.2581818181818183,
|
||||
"heapUsedBytes_sampled_max": 168546936,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 30,
|
||||
"workers": 8,
|
||||
"crash_round": 0,
|
||||
"churn_every": 0,
|
||||
"pause_redis": true
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 30,
|
||||
"completed_rounds": 30,
|
||||
"redis_stall": {
|
||||
"rejected_renewals": 96,
|
||||
"failed_snapshot_nodes": 3,
|
||||
"response_times": {
|
||||
"samples": 96,
|
||||
"p50_ms": 1013.8123999349773,
|
||||
"p95_ms": 4014.4751999760047,
|
||||
"p99_ms": 4015.4822999611497,
|
||||
"max_ms": 4015.4822999611497
|
||||
},
|
||||
"same_leases_recovered": true
|
||||
},
|
||||
"seconds": 20.33888840000145,
|
||||
"renewals": {
|
||||
"samples": 2880,
|
||||
"p50_ms": 8.835799992084503,
|
||||
"p95_ms": 23.678299970924854,
|
||||
"p99_ms": 35.42319999542087,
|
||||
"max_ms": 102.84149996004999
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 372,
|
||||
"p50_ms": 13.98350007366389,
|
||||
"p95_ms": 16.946100047789514,
|
||||
"p99_ms": 20.020700059831142,
|
||||
"max_ms": 20.943500101566315
|
||||
},
|
||||
"renewal_throughput": 141.6006589622565,
|
||||
"node_pids": [
|
||||
88080,
|
||||
78036,
|
||||
121352
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-68ad627e7be5-node-58312",
|
||||
"qqq-capacity-68ad627e7be5-node-58314",
|
||||
"qqq-capacity-68ad627e7be5-node-58315"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793032016,
|
||||
"duration_us": 2198,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793032016,
|
||||
2,
|
||||
2
|
||||
]
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-58312.log",
|
||||
"samples": 280,
|
||||
"observed_wall_ms": 27899,
|
||||
"cpu_seconds": 23.96,
|
||||
"cpu_core_equivalents": 0.8588121438044375,
|
||||
"heapUsedBytes_sampled_max": 164221400,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 6,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 15,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-58314.log",
|
||||
"samples": 289,
|
||||
"observed_wall_ms": 28800,
|
||||
"cpu_seconds": 23.2,
|
||||
"cpu_core_equivalents": 0.8055555555555556,
|
||||
"heapUsedBytes_sampled_max": 162849016,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 3,
|
||||
"poolWaiting_sampled_max": 14,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-58315.log",
|
||||
"samples": 272,
|
||||
"observed_wall_ms": 27100,
|
||||
"cpu_seconds": 23.85,
|
||||
"cpu_core_equivalents": 0.8800738007380073,
|
||||
"heapUsedBytes_sampled_max": 165779696,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 14,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"status": "FAIL",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 1000,
|
||||
"workers": 8
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"current_round": 480,
|
||||
"completed_rounds": 479,
|
||||
"error": "HTTP 503 node=56028 user=load-t0-u5 path=/presence/leases/renew: {\"error\":\"Presence is temporarily unavailable. Please retry.\",\"code\":\"ERROR\",\"message\":\"Presence is temporarily unavailable. Please retry.\",\"traceId\":\"cea30b82-e82b-49ec-af8a-64d8b6bd5af6\"}",
|
||||
"redis_slow_commands": [],
|
||||
"redis_latency_events": [],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-56027.log",
|
||||
"samples": 1067,
|
||||
"observed_wall_ms": 106600,
|
||||
"cpu_seconds": 94.328125,
|
||||
"cpu_core_equivalents": 0.8848792213883677,
|
||||
"heapUsedBytes_sampled_max": 160559696,
|
||||
"heapCommittedBytes_sampled_max": 178257920,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-56028.log",
|
||||
"samples": 1066,
|
||||
"observed_wall_ms": 106501,
|
||||
"cpu_seconds": 95.8125,
|
||||
"cpu_core_equivalents": 0.8996394400052582,
|
||||
"heapUsedBytes_sampled_max": 160251104,
|
||||
"heapCommittedBytes_sampled_max": 178257920,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-56029.log",
|
||||
"samples": 1066,
|
||||
"observed_wall_ms": 106500,
|
||||
"cpu_seconds": 95.25,
|
||||
"cpu_core_equivalents": 0.8943661971830986,
|
||||
"heapUsedBytes_sampled_max": 157995216,
|
||||
"heapCommittedBytes_sampled_max": 175112192,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -18,25 +18,18 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<!-- 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-SNAPSHOT</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>
|
||||
<!-- qbit-email from Maven Central -->
|
||||
<qbit-email.version>0.1.0-RC.11</qbit-email.version>
|
||||
<!-- Core RC.22 (QBitMetaDataProducer, VirtualFieldValueProviderInterface, QServiceUnavailableException); BLOCKED until it resolves from Central/the registry in CI. -->
|
||||
<qqq.version>4.1.0-RC.26</qqq.version>
|
||||
<!-- qbit-redis first release candidate of release/0.1 (producer run 5119 did not publish it yet): BLOCKED, verify the published artifact before pushing. -->
|
||||
<qbit-redis.version>0.2.0-RC.1</qbit-redis.version>
|
||||
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.2.1</version>
|
||||
<version>2.1.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -75,33 +68,15 @@
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-redis</artifactId>
|
||||
<version>${qbit-redis.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-object-storage</artifactId>
|
||||
<version>0.1.0-RC.10</version>
|
||||
<version>0.1.0-RC.4</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
@@ -135,47 +110,6 @@
|
||||
<version>3.27.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- upload journal and attachment transaction suites: real JDBC (H2, MySQL and PostgreSQL 17 containers) -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.4.240</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-mysql</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.8</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- native PostgreSQL module (vendor/type "postgres"), version managed by the qqq BOM -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-postgres</artifactId>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -199,14 +133,6 @@
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-resources-plugin</artifactId><version>3.3.1</version>
|
||||
<executions><execution><id>package-license-notices</id><phase>process-resources</phase><goals><goal>copy-resources</goal></goals>
|
||||
<configuration><outputDirectory>${project.build.outputDirectory}/META-INF</outputDirectory><resources><resource>
|
||||
<directory>${project.basedir}</directory><filtering>false</filtering><includes><include>LICENSE</include><include>NOTICE</include></includes>
|
||||
</resource></resources></configuration>
|
||||
</execution></executions>
|
||||
</plugin>
|
||||
<!-- the artifact version, for the help bundle version test -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
@@ -295,4 +221,30 @@
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
|
||||
<!-- imports the released 4.1 BOM, this profile is active by default and -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.15 ahead of the parent's 4.0.0 BOM. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.snapshot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -47,11 +47,9 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
private List<String> mirroredSecurityKeyTypes = List.of();
|
||||
|
||||
private QCodeReference notifier = null;
|
||||
private com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig redisPresence;
|
||||
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();
|
||||
@@ -66,7 +64,6 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
private Boolean adminApp = true;
|
||||
private Boolean uiApi = true;
|
||||
private QCodeReference attachmentStorage = new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentStorage.class);
|
||||
private QCodeReference attachmentLifecycle = new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentLifecyclePolicy.class);
|
||||
private Integer recentsPerUser = 200;
|
||||
private String attachmentsBucketName = "collaboration";
|
||||
private Long defaultMaxBytes = 100L * 1024 * 1024;
|
||||
@@ -106,23 +103,6 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted host policy for retained attachments and access auditing.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getAttachmentLifecycle()
|
||||
{
|
||||
return (attachmentLifecycle);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure lifecycle enforcement without coupling to a consuming qbit.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withAttachmentLifecycle(QCodeReference attachmentLifecycle)
|
||||
{
|
||||
this.attachmentLifecycle = java.util.Objects.requireNonNull(attachmentLifecycle, "attachmentLifecycle");
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -317,16 +297,11 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
@Override
|
||||
public void validate(QInstance instance, List<String> errors)
|
||||
{
|
||||
if(redisPresence != null)
|
||||
{
|
||||
redisPresence.validate(errors);
|
||||
}
|
||||
if(assertCondition(StringUtils.hasContent(backendName), "backendName is required for Collaboration", errors))
|
||||
{
|
||||
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)
|
||||
@@ -552,27 +527,6 @@ 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.
|
||||
*******************************************************************************/
|
||||
@@ -720,21 +674,4 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
{
|
||||
this.managedIndexes = managedIndexes;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Optional shared Redis presence; absent retains the existing memory provider.
|
||||
***************************************************************************/
|
||||
public com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig getRedisPresence()
|
||||
{
|
||||
return (redisPresence);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Select shared presence with explicit host-derived tenant scope.
|
||||
***************************************************************************/
|
||||
public CollaborationQBitConfig withRedisPresence(com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig value)
|
||||
{
|
||||
redisPresence = value;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -155,10 +155,6 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
public MetaDataProducerMultiOutput produce(QInstance instance) throws QException
|
||||
{
|
||||
qBitConfig.validate(instance);
|
||||
var realtime = com.kingsrook.qqq.backend.core.realtime.RealtimeMetaData.of(instance);
|
||||
realtime.withAudienceProvider(com.kingsrook.qbits.collaboration.presence.RedisPresenceAudience.EVENT,
|
||||
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.collaboration.presence.RedisPresenceAudience.class));
|
||||
instance.withSupplementalMetaData(realtime);
|
||||
QBitMetaData qBit = getQBitMetaData();
|
||||
instance.addQBit(qBit);
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
@@ -187,10 +183,6 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
com.kingsrook.qbits.collaboration.tags.TagProcesses.processesFor(table).forEach(output::add);
|
||||
}
|
||||
output.add(DeleteAttachmentsProcess.metadata());
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.RetryAttachmentCleanupProcess.metadata(qBitConfig));
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.ResolveAttachmentUploadProcess.metadata());
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.RecoverRetainedUploadProcess.metadata());
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.AdoptRetainedAttachmentProcess.metadata());
|
||||
output.add(FeatureSettingsDefaultsProcess.process());
|
||||
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
|
||||
{
|
||||
|
||||
@@ -33,8 +33,7 @@ import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
|
||||
|
||||
/*******************************************************************************
|
||||
** Identifier-only hints, published after successful commit. A tag assignment
|
||||
** also clears what this node remembers about untagged tables (TaggedTables).
|
||||
** Identifier-only hints, published after successful commit.
|
||||
*******************************************************************************/
|
||||
public class CollaborationRealtime implements RecordChangeListenerInterface
|
||||
{
|
||||
@@ -56,7 +55,6 @@ 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));
|
||||
@@ -72,13 +70,6 @@ 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
|
||||
|
||||
@@ -23,8 +23,6 @@ package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
@@ -38,8 +36,6 @@ 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.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -78,15 +74,7 @@ public final class CollaborationRecords
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (query(table, filter, QContext.getQBackendTransaction()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider reads participate in the originating query's explicit transaction.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(transaction);
|
||||
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(serviceMetadata(table));
|
||||
input.withPermissionBypass("Trusted collaboration service performs parent/ownership authorization.");
|
||||
return (new QueryAction().execute(input).getRecords().stream().map(QRecord::new).toList());
|
||||
@@ -97,34 +85,8 @@ public final class CollaborationRecords
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String table, QRecord row) throws QException
|
||||
{
|
||||
return (insert(serviceMetadata(table), row));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Activity belongs to the authorized source operation; timeline controls its
|
||||
** presentation. Preserve native parent checks and mirrored security keys.
|
||||
*******************************************************************************/
|
||||
public static QRecord insertActivity(QRecord row, RecordFeature sourceFeature) throws QException
|
||||
{
|
||||
QTableMetaData metadata = serviceMetadata(CollaborationTables.ACTIVITY);
|
||||
RecordReferenceTableMetaData original = RecordReferenceTableMetaData.of(metadata);
|
||||
if(original == null || sourceFeature == null)
|
||||
{
|
||||
throw (new QException("Activity source authorization metadata is required."));
|
||||
}
|
||||
RecordReferenceTableMetaData reference = (RecordReferenceTableMetaData) original.clone();
|
||||
reference.withFeature(sourceFeature);
|
||||
metadata.withSupplementalMetaData(reference);
|
||||
return (insert(metadata, row));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A private metadata copy retains the same transaction and validation path.
|
||||
*******************************************************************************/
|
||||
private static QRecord insert(QTableMetaData metadata, QRecord row) throws QException
|
||||
{
|
||||
InsertInput input = new InsertInput(metadata.getName()).withRecords(List.of(row)).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(metadata);
|
||||
InsertInput input = new InsertInput(table).withRecords(List.of(row)).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(serviceMetadata(table));
|
||||
input.withPermissionBypass("Trusted collaboration service authorized this write.");
|
||||
List<QRecord> result = new InsertAction().execute(input).getRecords();
|
||||
check(result);
|
||||
@@ -181,10 +143,7 @@ public final class CollaborationRecords
|
||||
public static QTableMetaData cleanupMetadata(String table)
|
||||
{
|
||||
QTableMetaData metadata = serviceMetadata(table);
|
||||
if(metadata.getSupplementalMetaData() != null)
|
||||
{
|
||||
metadata.getSupplementalMetaData().remove(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData.NAME);
|
||||
}
|
||||
metadata.getSupplementalMetaData().remove(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData.NAME);
|
||||
metadata.setRecordSecurityLocks(null);
|
||||
return (metadata);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ public final class CollaborationSchema
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String type = field.getName().equals(table.getPrimaryKeyField()) ? "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY" : sqlType(field);
|
||||
columns.add(" " + field.getBackendName() + " " + type + (field.getName().equals("parentDeleted") || field.getName().equals("retained") ? " DEFAULT FALSE" : "") + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
|
||||
columns.add(" " + field.getBackendName() + " " + type + (field.getName().equals("parentDeleted") ? " DEFAULT FALSE" : "") + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
|
||||
}
|
||||
statements.add("CREATE TABLE IF NOT EXISTS " + table.getName() + " (\n" + String.join(",\n", columns) + "\n)");
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
@@ -78,10 +78,6 @@ public final class CollaborationSchema
|
||||
}
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_record_tag_tag ON collaboration_record_tag (tag_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_parent ON collaboration_attachment (table_name, record_id)");
|
||||
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS retained BOOLEAN NOT NULL DEFAULT FALSE");
|
||||
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS intake_key VARCHAR(64)");
|
||||
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS intake_fingerprint VARCHAR(64)");
|
||||
statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_attachment_intake ON collaboration_attachment (intake_key) WHERE intake_key IS NOT NULL");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_parent ON collaboration_comment (table_name, record_id, last_activity_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_created ON collaboration_comment (table_name, record_id, create_date)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_thread ON collaboration_comment (thread_id, create_date, id)");
|
||||
@@ -99,15 +95,6 @@ public final class CollaborationSchema
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_assignment_owner ON collaboration_record_assignment (owner_user_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_assignment_assignee ON collaboration_record_assignment (assignee_user_id)");
|
||||
statements.add("ALTER TABLE collaboration_mention ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS resolved_by VARCHAR(255)");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS settlement_evidence VARCHAR(2000)");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ALTER COLUMN attachment_id DROP NOT NULL");
|
||||
statements.add("DROP INDEX IF EXISTS uq_collaboration_attachment_cleanup_1");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_storage_key ON collaboration_attachment USING HASH (storage_key)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_cleanup_due ON collaboration_attachment_cleanup (next_attempt_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_cleanup_pending ON collaboration_attachment_cleanup (next_attempt_at, id) WHERE resolved_at IS NULL");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_mention_unread ON collaboration_mention (mentioned_user_id) WHERE notified_at IS NOT NULL AND read_at IS NULL");
|
||||
return (String.join(";\n", statements) + ";\n");
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public class CollaborationSummaryProvider implements RecordFeatureSummaryProvide
|
||||
String user = PersonalRecords.currentUserId();
|
||||
if(features.isEnabled(RecordFeature.PRESENCE))
|
||||
{
|
||||
summary.setPresence(new com.kingsrook.qbits.collaboration.presence.PresenceService().snapshot(ref.tableName(), id).stream().map(viewer ->
|
||||
summary.setPresence(com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).snapshot(ref.tableName(), id).stream().map(viewer ->
|
||||
{
|
||||
java.util.Map<String, java.io.Serializable> entry = new java.util.LinkedHashMap<>();
|
||||
entry.put("userId", viewer.userId());
|
||||
|
||||
@@ -29,12 +29,10 @@ import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterfa
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceHelpContentManager;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
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.QInstance;
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish committed policy changes through the core overlay.
|
||||
@@ -79,7 +77,7 @@ public class FeatureSettingsCustomizer implements TableCustomizerInterface
|
||||
var instance = QContext.getQInstance();
|
||||
if(transaction == null)
|
||||
{
|
||||
refreshMetadata(instance);
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -88,7 +86,7 @@ public class FeatureSettingsCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> refreshMetadata(instance));
|
||||
QContext.withTemporaryContext(context, () -> RuntimeMetaDataOverlay.refresh(instance));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
@@ -97,13 +95,4 @@ public class FeatureSettingsCustomizer implements TableCustomizerInterface
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind persisted help after the overlay creates its dynamic fields and sections.
|
||||
*******************************************************************************/
|
||||
private static void refreshMetadata(QInstance instance) throws QException
|
||||
{
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
QInstanceHelpContentManager.reloadHelpContent(instance);
|
||||
}
|
||||
}
|
||||
@@ -288,16 +288,12 @@ public final class TargetResolver
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard existence before invoking the core table permission helper. The read
|
||||
** is asked as the query it stands for: core hands a table's custom permission
|
||||
** checker the action input and not the permission asked, so a checker that
|
||||
** tells a read from a write by the input's type refuses an untyped input, and
|
||||
** its table was then no target for the people it lets read it.
|
||||
** Guard existence before invoking the core table permission helper.
|
||||
*******************************************************************************/
|
||||
private static boolean canReadTable(String table)
|
||||
{
|
||||
return (table != null && QContext.getQInstance().getTable(table) != null
|
||||
&& PermissionsHelper.hasTablePermission(new QueryInput(table), table, TablePermissionSubType.READ));
|
||||
&& PermissionsHelper.hasTablePermission(new AbstractActionInput(), table, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal activity writer; the calling service has already checked the parent.
|
||||
@@ -47,28 +46,8 @@ public final class ActivityWriter
|
||||
*******************************************************************************/
|
||||
public static void write(String table, String recordId, String type, String summary, String refTable, String refId) throws QException
|
||||
{
|
||||
CollaborationRecords.insertActivity(new QRecord().withValue("tableName", table).withValue("recordId", recordId)
|
||||
CollaborationRecords.insert(CollaborationTables.ACTIVITY, new QRecord().withValue("tableName", table).withValue("recordId", recordId)
|
||||
.withValue("at", Instant.now()).withValue("actorUserId", CollaborationSecurity.userId(QContext.getQSession()))
|
||||
.withValue("type", type).withValue("summary", summary).withValue("refTableName", refTable).withValue("refId", refId).withValue("parentDeleted", false), sourceFeature(refTable));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Source-specific activity requires that feature; generic events retain the
|
||||
** existing timeline authorization contract.
|
||||
*******************************************************************************/
|
||||
private static RecordFeature sourceFeature(String table)
|
||||
{
|
||||
return (switch(table == null ? "" : table)
|
||||
{
|
||||
case CollaborationTables.ATTACHMENT -> RecordFeature.ATTACHMENTS;
|
||||
case CollaborationTables.COMMENT, CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION -> RecordFeature.COMMENTS;
|
||||
case CollaborationTables.TAG, CollaborationTables.RECORD_TAG -> RecordFeature.TAGS;
|
||||
case CollaborationTables.FIELD_DEFINITION, CollaborationTables.FIELD_VALUE -> RecordFeature.CUSTOM_FIELDS;
|
||||
case CollaborationTables.TASK -> RecordFeature.TASKS;
|
||||
case CollaborationTables.RECORD_ASSIGNMENT -> RecordFeature.ASSIGNMENT;
|
||||
case CollaborationTables.FOLLOW, CollaborationTables.FOLLOW_DIGEST -> RecordFeature.FOLLOW;
|
||||
case CollaborationTables.SHARE -> RecordFeature.SHARING;
|
||||
default -> RecordFeature.TIMELINE;
|
||||
});
|
||||
.withValue("type", type).withValue("summary", summary).withValue("refTableName", refTable).withValue("refId", refId).withValue("parentDeleted", false));
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,9 @@ public final class CollaborationApi
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.3.0")
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.1.0")
|
||||
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence")
|
||||
.withSpec(PostPresenceSpec.class).withSpec(DeletePresenceSpec.class).withSpec(GetPresenceSpec.class)
|
||||
.withSpec(OpenPresenceLeaseSpec.class).withSpec(RenewPresenceLeaseSpec.class)
|
||||
.withSpec(ListFavoritesSpec.class)
|
||||
.withSpec(StarFavoriteSpec.class)
|
||||
.withSpec(UnstarFavoriteSpec.class)
|
||||
@@ -97,7 +96,6 @@ public final class CollaborationApi
|
||||
.withSpec(ListMyTasksSpec.class)
|
||||
.withSpec(PutAssignmentSpec.class)
|
||||
.withSpec(GetMyWorkSpec.class)
|
||||
.withSpec(MarkMentionsReadSpec.class)
|
||||
.withSpec(UploadAttachmentSpec.class));
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,7 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Service temporarily unavailable", BasicErrorResponseV1.class.getSimpleName())));
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -214,19 +213,6 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
|
||||
protected static <T> Page<T> page(JsonInput input, List<T> values) throws QBadRequestException
|
||||
{
|
||||
int limit = limit(input);
|
||||
PagePosition position = pagePosition(input);
|
||||
int start = Math.min(position.offset(), values.size());
|
||||
int end = start + Math.min(limit, values.size() - start);
|
||||
String next = end < values.size() ? position.cursor(end) : null;
|
||||
return (new Page<>(values.subList(start, end), next));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared existing offset cursor contract, also used by bounded native lists.
|
||||
** Scope is a routing check, never authorization or a signed capability.
|
||||
*******************************************************************************/
|
||||
protected static PagePosition pagePosition(JsonInput input) throws QBadRequestException
|
||||
{
|
||||
int offset = 0;
|
||||
String scope = input.getSpec().defineBasicOperation().getPath() + input.getPathParams().toString()
|
||||
+ ":" + input.getQueryParam("table") + ":" + input.getQueryParam("pk") + ":" + input.getQueryParam("q");
|
||||
@@ -252,25 +238,10 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
|
||||
throw (new QBadRequestException("Invalid cursor"));
|
||||
}
|
||||
}
|
||||
return (new PagePosition(offset, prefix));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Same version, scope and offset bytes as previously emitted by page().
|
||||
*******************************************************************************/
|
||||
protected record PagePosition(int offset, String prefix)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Encode only a representable nonnegative continuation offset.
|
||||
*******************************************************************************/
|
||||
String cursor(long next) throws QBadRequestException
|
||||
{
|
||||
if(next < 0 || next > Integer.MAX_VALUE)
|
||||
{
|
||||
throw (new QBadRequestException("Invalid cursor"));
|
||||
}
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString((prefix + next).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
int start = Math.min(offset, values.size());
|
||||
int end = start + Math.min(limit, values.size() - start);
|
||||
String next = end < values.size() ? Base64.getUrlEncoder().withoutPadding().encodeToString((prefix + end).getBytes(StandardCharsets.UTF_8)) : null;
|
||||
return (new Page<>(values.subList(start, end), next));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+9
-81
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.collaboration.api;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
@@ -41,67 +42,11 @@ public final class CollaborationPresenceSchemas
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Target for invisible server-issued renewal authority.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceOpenV1(String table, String pk) implements ToSchema
|
||||
{
|
||||
/***************************************************************************
|
||||
** Opening alone never advertises a viewer.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.of(
|
||||
"table", text("Parent table name.", false), "pk", text("Optional record key.", true)))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Opaque handle bound to the authenticated caller and target.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceLeaseV1(String connectionId) implements ToSchema
|
||||
{
|
||||
/***************************************************************************
|
||||
** Activate with a positive sequence; acquire a new handle after expiry.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(Map.of(
|
||||
"connectionId", text("Server-issued handle. Renew before 45 seconds; departure is terminal.", false))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable machine-readable response for missing renewal authority.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceExpiredV1(String code) implements ToSchema
|
||||
{
|
||||
/***************************************************************************
|
||||
** Clients rejoin only if their surface is still active and visible.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(Map.of(
|
||||
"code", text("Open a fresh lease before retrying renewal.", false).withEnumValues(List.of("PRESENCE_LEASE_EXPIRED")))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All identity information is derived from the authenticated session.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceRequestV1(String connectionId, String table, String pk, String mode, String fieldName, Long sequence) implements ToSchema
|
||||
public record CollaborationPresenceRequestV1(String connectionId, String table, String pk, String mode, String fieldName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve source callers that do not yet send sequence numbers.
|
||||
*******************************************************************************/
|
||||
public CollaborationPresenceRequestV1(String connectionId, String table, String pk, String mode, String fieldName)
|
||||
{
|
||||
this(connectionId, table, pk, mode, fieldName, null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clients renew every twenty seconds; the lease expires after forty-five.
|
||||
*******************************************************************************/
|
||||
@@ -113,24 +58,15 @@ public final class CollaborationPresenceSchemas
|
||||
"connectionId", text("Client connection identity.", false), "table", text("Parent table name.", false),
|
||||
"pk", text("Optional parent key; omit for a table view.", true),
|
||||
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")),
|
||||
"fieldName", text("Optional focused field on a record.", true),
|
||||
"sequence", sequenceSchema()))));
|
||||
"fieldName", text("Optional focused field on a record.", true)))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave removes only this authenticated user's connection and target.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceLeaveV1(String connectionId, String table, String pk, Long sequence) implements ToSchema
|
||||
public record CollaborationPresenceLeaveV1(String connectionId, String table, String pk) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve source callers that do not yet send sequence numbers.
|
||||
*******************************************************************************/
|
||||
public CollaborationPresenceLeaveV1(String connectionId, String table, String pk)
|
||||
{
|
||||
this(connectionId, table, pk, null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave does not need a mode or a field.
|
||||
*******************************************************************************/
|
||||
@@ -140,14 +76,15 @@ public final class CollaborationPresenceSchemas
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Remove the caller's lease on this connection and target.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"connectionId", text("Client connection identity.", false), "table", text("Parent table name.", false),
|
||||
"pk", text("Optional parent key.", true), "sequence", sequenceSchema()))));
|
||||
"pk", text("Optional parent key.", true)))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The snapshot is also the data shape of presence.changed events.
|
||||
** The snapshot is also the data shape of presence.changed events. Viewers are
|
||||
** included even when empty, whatever inclusion the serializer defaults to.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers) implements ToSchema
|
||||
public record CollaborationPresenceSnapshotV1(String table, String pk, @JsonInclude(JsonInclude.Include.ALWAYS) List<PresenceRegistry.Viewer> viewers) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A table snapshot deliberately excludes record ids and focused field names.
|
||||
@@ -158,7 +95,7 @@ public final class CollaborationPresenceSchemas
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Live viewers, merged across tabs; presence.changed uses this payload.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"table", text("Parent table name.", false), "pk", text("Record key; absent for table events.", true),
|
||||
"viewers", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
|
||||
"viewers", new Schema().withType(Type.ARRAY).withDescription("Live viewers; always present, empty once the last viewer leaves.").withNullable(false).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,13 +124,4 @@ public final class CollaborationPresenceSchemas
|
||||
{
|
||||
return (new Schema().withType(Type.STRING).withDescription(description).withNullable(nullable));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional for legacy clients; new clients increment on every renewal and leave.
|
||||
*******************************************************************************/
|
||||
private static Schema sequenceSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.INTEGER).withNullable(true)
|
||||
.withDescription("Monotonically increasing nonnegative safe integer per connection and target; orders delayed requests."));
|
||||
}
|
||||
}
|
||||
@@ -70,17 +70,8 @@ public final class CollaborationSchemas
|
||||
/*******************************************************************************
|
||||
** An attachment visible through its parent record; no storage coordinates are exposed.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName, Boolean canEdit) implements ToSchema
|
||||
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve construction compatibility with the earlier attachment body.
|
||||
*******************************************************************************/
|
||||
public CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType,
|
||||
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName)
|
||||
{
|
||||
this(id, tableName, recordId, fileName, contentType, sizeBytes, sha256, description, createdByUserId, createDate, canDelete, createdByDisplayName, canDelete);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@@ -98,8 +89,7 @@ public final class CollaborationSchemas
|
||||
Map.entry("description", new Schema().withType(Type.STRING).withDescription("Optional description.").withNullable(true)),
|
||||
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("Uploader identity.").withNullable(true)),
|
||||
Map.entry("createDate", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Upload time.").withNullable(true)),
|
||||
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may delete this attachment; retained files cannot be deleted.").withNullable(true)),
|
||||
Map.entry("canEdit", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change the attachment filename or description.").withNullable(true)),
|
||||
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true)),
|
||||
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Uploader display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
@@ -500,16 +490,8 @@ public final class CollaborationSchemas
|
||||
/*******************************************************************************
|
||||
** Temporary access to file bytes. Clients follow url.
|
||||
*******************************************************************************/
|
||||
public record CollaborationDownloadV1(String url, String delivery) implements ToSchema
|
||||
public record CollaborationDownloadV1(String url) implements ToSchema
|
||||
{
|
||||
/***************************************************************************
|
||||
** Existing callers retain the storage URL contract.
|
||||
***************************************************************************/
|
||||
public CollaborationDownloadV1(String url)
|
||||
{
|
||||
this(url, "SIGNED_URL");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@@ -517,8 +499,7 @@ public final class CollaborationSchemas
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Temporary access to file bytes. Clients follow url.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("url", new Schema().withType(Type.STRING).withDescription("Temporary delivery URL; never persist it.").withNullable(true)),
|
||||
Map.entry("delivery", new Schema().withType(Type.STRING).withDescription("SIGNED_URL or AUTHENTICATED_FETCH. Authenticated fetch requires the trusted native API transport and a durable receipt check.").withNullable(true))))));
|
||||
Map.entry("url", new Schema().withType(Type.STRING).withDescription("Presigned URL, valid for five minutes. Clients follow url; never persist it.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,9 +718,6 @@ public final class CollaborationSchemas
|
||||
schemas.put("CollaborationPresenceLeaveV1", new CollaborationPresenceSchemas.CollaborationPresenceLeaveV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceSnapshotV1", new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceViewerV1", new CollaborationPresenceSchemas.CollaborationPresenceViewerV1(null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceOpenV1", new CollaborationPresenceSchemas.CollaborationPresenceOpenV1(null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceLeaseV1", new CollaborationPresenceSchemas.CollaborationPresenceLeaseV1(null).toSchema());
|
||||
schemas.put("CollaborationPresenceExpiredV1", new CollaborationPresenceSchemas.CollaborationPresenceExpiredV1(null).toSchema());
|
||||
schemas.putAll(CollaborationWorkSchemas.components());
|
||||
return (schemas);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ final class CollaborationViews
|
||||
Map<String, String> names = names(values.stream().map(AttachmentEntry::createdByUserId).toArray(String[]::new));
|
||||
return (values.stream().map(value -> new CollaborationAttachmentV1(
|
||||
value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete(),
|
||||
name(names, value.createdByUserId()), value.canEdit())).toList());
|
||||
name(names, value.createdByUserId()))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -76,7 +76,7 @@ public class DeletePresenceSpec extends CollaborationJsonSpec
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceLeaveV1.class);
|
||||
new PresenceService().leave(body.connectionId(), required(body.table(), "table"), body.pk(), body.sequence());
|
||||
new PresenceService().leave(body.connectionId(), required(body.table(), "table"), body.pk());
|
||||
return (success());
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,6 @@ public class DownloadAttachmentSpec extends CollaborationJsonSpec
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var delivery = new AttachmentsService().downloadDelivery(id(input));
|
||||
return (new CollaborationDownloadV1(delivery.url().toExternalForm(), delivery.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL"));
|
||||
return (new CollaborationDownloadV1(new AttachmentsService().downloadUrl(id(input), false).toExternalForm()));
|
||||
}
|
||||
}
|
||||
@@ -68,10 +68,7 @@ public class ListAttachmentsSpec extends CollaborationJsonSpec
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
int limit = limit(input);
|
||||
var position = pagePosition(input);
|
||||
var page = new AttachmentsService().listPage(table(input), pk(input), position.offset(), limit);
|
||||
String next = page.hasMore() ? position.cursor((long) position.offset() + page.items().size()) : null;
|
||||
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), next));
|
||||
var page = page(input, new AttachmentsService().list(table(input), pk(input)));
|
||||
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -1,71 +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.collaboration.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark all currently visible unread mentions read.
|
||||
*******************************************************************************/
|
||||
public class MarkMentionsReadSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/my-work/mentions/read", "Mark my visible mentions read"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationMutationResultV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the service as the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.mywork.MyWorkService().markMentionsRead(null);
|
||||
return (success());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,81 +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.collaboration.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew presence with fresh parent access checks.
|
||||
*******************************************************************************/
|
||||
public class OpenPresenceLeaseSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the extension operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/presence/leases", "Open presence lease"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit query parameters for polling.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationPresenceLeaseV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare a typed JSON request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationPresenceOpenV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services derive identity from QContext and enforce visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceOpenV1.class);
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceLeaseV1(new PresenceService().openLease(required(body.table(), "table"), body.pk())));
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,6 @@ public class PostPresenceSpec extends CollaborationJsonSpec
|
||||
{
|
||||
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceRequestV1.class);
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(body.table(), body.pk(),
|
||||
new PresenceService().heartbeat(body.connectionId(), required(body.table(), "table"), body.pk(), body.mode(), body.fieldName(), body.sequence())));
|
||||
new PresenceService().heartbeat(body.connectionId(), required(body.table(), "table"), body.pk(), body.mode(), body.fieldName())));
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,6 @@ public class PreviewAttachmentSpec extends CollaborationJsonSpec
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var delivery = new AttachmentsService().downloadDelivery(id(input), true);
|
||||
return (new CollaborationDownloadV1(delivery.url().toExternalForm(), delivery.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL"));
|
||||
return (new CollaborationDownloadV1(new AttachmentsService().downloadUrl(id(input), true).toExternalForm()));
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public class PutAssignmentSpec extends CollaborationJsonSpec
|
||||
{
|
||||
var body = body(input, CollaborationWorkSchemas.CollaborationAssignmentRequestV1.class);
|
||||
return (CollaborationViews.assignment(new com.kingsrook.qbits.collaboration.assignment.AssignmentService()
|
||||
.assignRoles(table(input), pk(input), body.ownerUserId(), body.assigneeUserId(), input.getBody().keySet())));
|
||||
.assign(table(input), pk(input), body.ownerUserId(), body.assigneeUserId())));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,112 +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.collaboration.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew presence with fresh parent access checks.
|
||||
*******************************************************************************/
|
||||
public class RenewPresenceLeaseSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Expiry is distinct from permissions, invalid input and service outages.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<com.kingsrook.qqq.middleware.javalin.specs.BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
var responses = new java.util.ArrayList<>(super.defineAdditionalBasicResponses());
|
||||
responses.add(new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.CONFLICT,
|
||||
"Presence lease expired; open a new lease.", "CollaborationPresenceExpiredV1"));
|
||||
return (responses);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve standard framework mapping for every other exception.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void handleException(io.javalin.http.Context context, Exception exception)
|
||||
{
|
||||
if(com.kingsrook.qqq.backend.core.utils.ExceptionUtils.findClassInRootChain(exception,
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class) != null)
|
||||
{
|
||||
context.status(409).contentType("application/json").result(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(
|
||||
new CollaborationPresenceSchemas.CollaborationPresenceExpiredV1("PRESENCE_LEASE_EXPIRED")));
|
||||
}
|
||||
else
|
||||
{
|
||||
super.handleException(context, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the extension operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/presence/leases/renew", "Renew issued presence lease"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit query parameters for polling.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationPresenceSnapshotV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare a typed JSON request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationPresenceRequestV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services derive identity from QContext and enforce visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceRequestV1.class);
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(body.table(), body.pk(),
|
||||
new PresenceService().renewLease(body.connectionId(), required(body.table(), "table"), body.pk(), body.mode(), body.fieldName(), body.sequence())));
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ public final class CollaborationHome
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.withWidgets(HOME_WIDGETS)
|
||||
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
|
||||
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.ATTACHMENT_CLEANUP, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
|
||||
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
|
||||
app.setParentAppName(config.getParentAppName());
|
||||
return (app);
|
||||
}
|
||||
@@ -267,29 +267,7 @@ public final class CollaborationHome
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if(admin)
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
var redis = com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig.forInstance(instance);
|
||||
Map<String, Integer> counts;
|
||||
if(redis == null)
|
||||
{
|
||||
counts = PresenceRegistry.get(instance).counts();
|
||||
}
|
||||
else
|
||||
{
|
||||
counts = new java.util.TreeMap<>();
|
||||
var backend = new com.kingsrook.qbits.collaboration.presence.RedisPresenceBackend(redis);
|
||||
// Read only known table rooms in the administrator's authenticated tenant.
|
||||
// Never scan Redis keys or silently substitute local counts on failure.
|
||||
for(String tableName : instance.getTables().keySet())
|
||||
{
|
||||
int viewers = backend.snapshot(tableName, null).size();
|
||||
if(viewers > 0)
|
||||
{
|
||||
counts.put(tableName, viewers);
|
||||
}
|
||||
}
|
||||
}
|
||||
counts.forEach((table, viewers) ->
|
||||
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("table", tableLabel(table));
|
||||
|
||||
@@ -53,42 +53,15 @@ public class AssignmentService
|
||||
** Full replacement of both roles; null clears a role.
|
||||
*******************************************************************************/
|
||||
public QRecord assign(String table, Serializable recordId, String ownerUserId, String assigneeUserId) throws QException
|
||||
{
|
||||
return (assignRoles(table, recordId, ownerUserId, assigneeUserId, java.util.Set.of("ownerUserId", "assigneeUserId")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Omitted roles remain unchanged; an explicitly supplied null clears a role.
|
||||
*******************************************************************************/
|
||||
public QRecord assignRoles(String table, Serializable recordId, String ownerUserId, String assigneeUserId, java.util.Set<String> roles) throws QException
|
||||
{
|
||||
ParentRecordAccess.requireFeature(table, recordId, "assign");
|
||||
String id = FollowService.canonicalId(table, recordId);
|
||||
if(roles == null || roles.isEmpty() || !java.util.Set.of("ownerUserId", "assigneeUserId").containsAll(roles))
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Supply ownerUserId or assigneeUserId; omitted roles remain unchanged."));
|
||||
}
|
||||
var instance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
|
||||
var ownership = com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver.resolve(instance, instance.getTable(table)).getOwnership();
|
||||
if(ownership != null && ((roles.contains("ownerUserId") && ownership.getOwnerFieldName() != null)
|
||||
|| (roles.contains("assigneeUserId") && ownership.getAssigneeFieldName() != null)))
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Update native ownership fields through the parent record's versioned update endpoint."));
|
||||
}
|
||||
WorkSupport.requireReader(ownerUserId, table, id);
|
||||
WorkSupport.requireReader(assigneeUserId, table, id);
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
Optional<QRecord> old = get(table, id);
|
||||
QRecord change = new QRecord();
|
||||
if(roles.contains("ownerUserId"))
|
||||
{
|
||||
change.setValue("ownerUserId", ownerUserId);
|
||||
}
|
||||
if(roles.contains("assigneeUserId"))
|
||||
{
|
||||
change.setValue("assigneeUserId", assigneeUserId);
|
||||
}
|
||||
QRecord change = new QRecord().withValue("ownerUserId", ownerUserId).withValue("assigneeUserId", assigneeUserId);
|
||||
QRecord row;
|
||||
if(old.isEmpty())
|
||||
{
|
||||
@@ -100,7 +73,7 @@ public class AssignmentService
|
||||
row = get(table, id).orElseThrow();
|
||||
}
|
||||
ActivityWriter.write(table, id, "ASSIGNED", "Record assignment changed", CollaborationTables.RECORD_ASSIGNMENT, row.getValueString("id"));
|
||||
if(roles.contains("assigneeUserId") && assigneeUserId != null && (old.isEmpty() || !Objects.equals(old.get().getValueString("assigneeUserId"), assigneeUserId)))
|
||||
if(assigneeUserId != null && (old.isEmpty() || !Objects.equals(old.get().getValueString("assigneeUserId"), assigneeUserId)))
|
||||
{
|
||||
new FollowService().ensureFollower(assigneeUserId, table, id, "ASSIGNED");
|
||||
if(!assigneeUserId.equals(PersonalRecords.currentUserId()))
|
||||
|
||||
-121
@@ -1,121 +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.collaboration.attachments;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A visible native adoption action for an existing original. It never
|
||||
** accepts replacement bytes, a storage path, an actor, or an intake scope.
|
||||
*******************************************************************************/
|
||||
public final class AdoptRetainedAttachmentProcess
|
||||
{
|
||||
public static final String NAME = "collaborationAdoptRetainedAttachment";
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata utility.
|
||||
*******************************************************************************/
|
||||
private AdoptRetainedAttachmentProcess()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard QQQ confirmation and result screens, attached to the attachment table.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata()
|
||||
{
|
||||
return (new QProcessMetaData().withName(NAME).withTableName(CollaborationTables.ATTACHMENT).withLabel("Retain existing original")
|
||||
.withMinInputRecords(1).withMaxInputRecords(1).withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class))
|
||||
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
|
||||
.withRecordListMetaData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData().withTableName(CollaborationTables.ATTACHMENT))))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("confirm").withLabel("Retain this original")
|
||||
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
|
||||
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("confirmAdoption", QFieldType.BOOLEAN).withLabel("Verify and retain the existing original without copying bytes").withDefaultValue(false).withIsRequired(true)))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("adopt").withCode(new QCodeReference(Adopt.class)))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("result").withLabel("Original retained")
|
||||
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
|
||||
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.VIEW_FORM))
|
||||
.withViewField(new QFieldMetaData("attachmentId", QFieldType.INTEGER).withLabel("Retained attachment")
|
||||
.withPossibleValueSourceName(com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve one visible attachment selection before displaying confirmation.
|
||||
*******************************************************************************/
|
||||
public static final class Prepare implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Read-only preview under current actor and parent visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(input.getRecords() == null || input.getRecords().size() != 1)
|
||||
{
|
||||
throw (new QException("Select exactly one attachment."));
|
||||
}
|
||||
var row = new AttachmentsService().requireAdoptable(input.getRecords().getFirst().getValueInteger("id"));
|
||||
if(row == null)
|
||||
{
|
||||
throw (new QException("Attachment is unavailable."));
|
||||
}
|
||||
if(row.getValueString("intakeKey") != null)
|
||||
{
|
||||
throw (new QException("Retry this upload through its original intake request to preserve its context."));
|
||||
}
|
||||
output.addValue("attachmentId", row.getValue("id"));
|
||||
output.addValue("confirmAdoption", false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execution reauthorizes independently of the earlier preview.
|
||||
*******************************************************************************/
|
||||
public static final class Adopt implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The owner returns only after mandatory lifecycle registration completes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(!Boolean.TRUE.equals(input.getValueBoolean("confirmAdoption")))
|
||||
{
|
||||
throw (new QException("Confirm adoption of this existing original to continue."));
|
||||
}
|
||||
output.addValue("attachmentId", new AttachmentsService().adoptRetainedAttachment(input.getValueInteger("attachmentId")).id());
|
||||
}
|
||||
}
|
||||
}
|
||||
-201
@@ -1,201 +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.collaboration.attachments;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
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.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional owner policy for parents whose dynamic visibility cannot be expressed
|
||||
** by aggregate SQL. Count/aggregate require one currently authorized parent.
|
||||
** Ordinary collaboration policies retain their existing count behavior.
|
||||
*******************************************************************************/
|
||||
public final class AttachmentAggregateGuard extends AttachmentSecurity
|
||||
{
|
||||
private static final String PINNED_POLICY = "collaboration.attachmentPinnedAggregateParent";
|
||||
|
||||
/*******************************************************************************
|
||||
** Hide unscoped list totals in both instance and table metadata. Native count
|
||||
** remains available to callers supplying the required authorized parent pin.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData configure(QTableMetaData table, CollaborationQBitConfig config)
|
||||
{
|
||||
if(policy(config).requiresPinnedAggregateParent())
|
||||
{
|
||||
table.withSupplementalMetaData(new PinnedParentPolicy());
|
||||
}
|
||||
if(table.getSupplementalMetaData(PINNED_POLICY) != null)
|
||||
{
|
||||
table.withoutCapabilities(Capability.TABLE_COUNT, Capability.QUERY_STATS);
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve ordinary view/write rules, then narrow aggregate inputs only.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
super.checkPermissionsThrowing(input, metadata);
|
||||
if(!(input instanceof CountInput) && !(input instanceof AggregateInput))
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
CollaborationQBitConfig config = (CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
|
||||
.filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig();
|
||||
QTableMetaData tableMetadata = QContext.getQInstance().getTable(CollaborationTables.ATTACHMENT);
|
||||
if(tableMetadata.getSupplementalMetaData(PINNED_POLICY) == null && !policy(config).requiresPinnedAggregateParent() && !hasRetainedOriginal())
|
||||
{
|
||||
return;
|
||||
}
|
||||
QQueryFilter filter = input instanceof CountInput count ? count.getFilter() : ((AggregateInput) input).getFilter();
|
||||
var joins = input instanceof CountInput count ? count.getQueryJoins() : ((AggregateInput) input).getQueryJoins();
|
||||
if(joins != null && !joins.isEmpty() || filter == null || filter.getBooleanOperator() != QQueryFilter.BooleanOperator.AND)
|
||||
{
|
||||
throw (new QException("A single pinned attachment parent is required for counts and aggregates."));
|
||||
}
|
||||
rejectSetOperations(filter, 0, new int[]{0});
|
||||
Serializable table = literal(filter, "tableName");
|
||||
Serializable record = literal(filter, "recordId");
|
||||
if(!(table instanceof String name) || name.isBlank() || record == null)
|
||||
{
|
||||
throw (new QException("A single pinned attachment parent is required for counts and aggregates."));
|
||||
}
|
||||
ParentRecordAccess.requireVisibleParent(name, record);
|
||||
}
|
||||
catch(QException | RuntimeException denied)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Counts and aggregates require one currently readable attachment parent."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A literal top-level AND equality constrains every row, even when other
|
||||
** independent subfilters contain OR. Conflicting or computed pins are refused.
|
||||
*******************************************************************************/
|
||||
private static Serializable literal(QQueryFilter filter, String field) throws QException
|
||||
{
|
||||
Serializable result = null;
|
||||
for(var criterion : filter.getCriteria() == null ? List.<com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria>of() : filter.getCriteria())
|
||||
{
|
||||
if(field.equals(criterion.getFieldName()) && criterion.getOperator() == QCriteriaOperator.EQUALS)
|
||||
{
|
||||
if(criterion.getValues() == null || criterion.getValues().size() != 1)
|
||||
{
|
||||
throw (new QException("An exact parent equality is required."));
|
||||
}
|
||||
Serializable value = criterion.getValues().getFirst();
|
||||
if(!(value instanceof String) && !(value instanceof Number) || result != null && !result.equals(value))
|
||||
{
|
||||
throw (new QException("Computed or conflicting parent equality is not supported."));
|
||||
}
|
||||
result = value;
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set-operation filters may combine independent branches; never assume their
|
||||
** SQL obeys an ordinary root AND pin. Bound traversal before SQL execution.
|
||||
*******************************************************************************/
|
||||
private static void rejectSetOperations(QQueryFilter filter, int depth, int[] visited) throws QException
|
||||
{
|
||||
if(depth > 64 || ++visited[0] > 4096 || filter.getSubFilterSetOperator() != null)
|
||||
{
|
||||
throw (new QException("Unsupported aggregate filter shape."));
|
||||
}
|
||||
for(QQueryFilter child : filter.getSubFilters() == null ? List.<QQueryFilter>of() : filter.getSubFilters())
|
||||
{
|
||||
rejectSetOperations(child, depth + 1, visited);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capability policy is configuration-only and must not depend on a request.
|
||||
*******************************************************************************/
|
||||
private static AttachmentLifecyclePolicy policy(CollaborationQBitConfig config)
|
||||
{
|
||||
return (java.util.Objects.requireNonNull(QCodeLoader.getAdHoc(AttachmentLifecyclePolicy.class, config.getAttachmentLifecycle()), "Attachment lifecycle policy is unavailable."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh instance may have lost its former lifecycle configuration. Inspect
|
||||
** only existence through private owner metadata, never return retained rows.
|
||||
** Failure propagates to aggregate denial; there is no permissive fallback.
|
||||
*******************************************************************************/
|
||||
private static boolean hasRetainedOriginal() throws QException
|
||||
{
|
||||
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(CollaborationTables.ATTACHMENT)
|
||||
.withFilter(new QQueryFilter().withCriteria("retained", QCriteriaOperator.EQUALS, true).withLimit(1))
|
||||
.withShouldFetchHeavyFields(false).withShouldGenerateDisplayValues(false);
|
||||
input.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
|
||||
input.withPermissionBypass("Owner aggregate guard checks only whether retained originals require pinned-parent authorization; no row data is exposed.");
|
||||
return (!new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(input).getRecords().isEmpty());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An installed capability restriction survives subsequent lifecycle changes
|
||||
** and metadata clones. It carries no request or authorization state.
|
||||
*******************************************************************************/
|
||||
public static final class PinnedParentPolicy extends QSupplementalTableMetaData
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Stable owner metadata identity.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (PINNED_POLICY);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The marker is immutable and has no nested mutable state.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData clone)
|
||||
{
|
||||
return (clone);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,7 @@ public class AttachmentCleanup
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> rows = com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupQuery(com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
|
||||
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList())
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.NOT_EQUALS, true).withLimit(200));
|
||||
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()).withLimit(200));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
return;
|
||||
|
||||
-527
@@ -1,527 +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.collaboration.attachments;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
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.query.QueryLockMode;
|
||||
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.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable deletion intent, committed with metadata removal before deleting bytes.
|
||||
*******************************************************************************/
|
||||
public class AttachmentCleanupQueue implements CustomPermissionChecker
|
||||
{
|
||||
private final AttachmentStorage storage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata permission checks do not create a storage client.
|
||||
*******************************************************************************/
|
||||
public AttachmentCleanupQueue()
|
||||
{
|
||||
this(new AttachmentStorage());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the same provider boundary as the authorized attachment operation.
|
||||
*******************************************************************************/
|
||||
AttachmentCleanupQueue(AttachmentStorage storage)
|
||||
{
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only admins may inspect recovery locations; no direct mutations are allowed.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(!(input instanceof QueryInput) && !(input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput)
|
||||
&& !(input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Attachment cleanup records are service-managed."));
|
||||
}
|
||||
requireOperator();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The caller has already authorized the parent and attachment owner.
|
||||
*******************************************************************************/
|
||||
void delete(QRecord row, boolean cleanup) throws QException
|
||||
{
|
||||
QBackendTransaction outer = QContext.getQBackendTransaction();
|
||||
if(outer != null)
|
||||
{
|
||||
QRecord pending = enqueueAndDelete(row, cleanup);
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
outer.addAfterCommitCallback(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> finish(pending));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Attachment cleanup remains durably queued.", e));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
QRecord pending;
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new DeleteInput(CollaborationTables.ATTACHMENT)))
|
||||
{
|
||||
QContext.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
pending = enqueueAndDelete(row, cleanup);
|
||||
transaction.commit();
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
transaction.rollback();
|
||||
throw (failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
}
|
||||
finish(pending);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Store the recovery locator in the same database transaction as row deletion.
|
||||
*******************************************************************************/
|
||||
private QRecord enqueueAndDelete(QRecord row, boolean cleanup) throws QException
|
||||
{
|
||||
QRecord intent = new QRecord().withValue("attachmentId", row.getValue("id"))
|
||||
.withValue("bucketName", row.getValue("bucketName")).withValue("storageKey", row.getValue("storageKey"));
|
||||
InsertInput insert = new InsertInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(intent))
|
||||
.withTransaction(QContext.getQBackendTransaction());
|
||||
insert.withPermissionBypass("Authorized attachment deletion records durable storage cleanup.");
|
||||
List<QRecord> inserted = new InsertAction().execute(insert).getRecords();
|
||||
AttachmentRecords.checkErrors(inserted);
|
||||
DeleteInput delete = new DeleteInput(CollaborationTables.ATTACHMENT).withPrimaryKeys(List.of(row.getValue("id")))
|
||||
.withTransaction(QContext.getQBackendTransaction());
|
||||
delete.setTableMetaData(cleanup ? CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT) : AttachmentRecords.storageTable());
|
||||
delete.withPermissionBypass("Attachment deletion was authorized before recording cleanup.");
|
||||
AttachmentRecords.checkErrors(new DeleteAction().execute(delete).getRecordsWithErrors());
|
||||
return (inserted.getFirst());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable upload ownership requires real transactional row locking.
|
||||
*******************************************************************************/
|
||||
static boolean supportsUploadOwnership() throws QException
|
||||
{
|
||||
var backend = QContext.getQInstance().getBackendForTable(CollaborationTables.ATTACHMENT_CLEANUP);
|
||||
return (new QBackendModuleDispatcher().getQBackendModule(backend).supportsQueryLockMode(backend, QueryLockMode.FOR_UPDATE_SKIP_LOCKED));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Commit the locator independently, then lock it before any provider write.
|
||||
** If a worker claimed it first, abort without uploading any bytes.
|
||||
*******************************************************************************/
|
||||
QRecord beginUpload(String bucket, String key) throws QException
|
||||
{
|
||||
if(!supportsUploadOwnership())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QBackendTransaction transaction = QContext.getQBackendTransaction();
|
||||
if(transaction == null)
|
||||
{
|
||||
throw (new QException("Durable attachment uploads require an active transaction."));
|
||||
}
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
QRecord[] pending = new QRecord[1];
|
||||
QContext.withTemporaryContext(context, () ->
|
||||
{
|
||||
InsertInput insert = new InsertInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(
|
||||
new QRecord().withValue("bucketName", bucket).withValue("storageKey", key)));
|
||||
insert.withPermissionBypass("Authorized upload records its immutable locator before external I/O.");
|
||||
List<QRecord> inserted = new InsertAction().execute(insert).getRecords();
|
||||
AttachmentRecords.checkErrors(inserted);
|
||||
pending[0] = inserted.getFirst();
|
||||
});
|
||||
if(lock(pending[0], transaction) == null)
|
||||
{
|
||||
throw (new QException("Upload cleanup intent was already claimed; retry the upload."));
|
||||
}
|
||||
Runnable cleanup = () ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> finish(pending[0]));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Upload cleanup remains durably queued.", e));
|
||||
}
|
||||
};
|
||||
transaction.addAfterRollbackCallback(cleanup);
|
||||
transaction.addAfterCommitCallback(cleanup);
|
||||
return (pending[0]);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish metadata and retire its upload intent in the same transaction.
|
||||
*******************************************************************************/
|
||||
void completeUpload(QRecord pending) throws QException
|
||||
{
|
||||
if(pending != null)
|
||||
{
|
||||
DeleteInput delete = new DeleteInput(CollaborationTables.ATTACHMENT_CLEANUP).withPrimaryKey(pending.getValue("id"))
|
||||
.withTransaction(QContext.getQBackendTransaction());
|
||||
delete.withPermissionBypass("Attachment metadata now owns the uploaded object in this transaction.");
|
||||
AttachmentRecords.checkErrors(new DeleteAction().execute(delete).getRecordsWithErrors());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Skip active owners without waiting or updating their locked intent.
|
||||
*******************************************************************************/
|
||||
private QRecord lock(QRecord pending, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
QueryInput query = new QueryInput(CollaborationTables.ATTACHMENT_CLEANUP).withTransaction(transaction)
|
||||
.withLockMode(QueryLockMode.FOR_UPDATE_SKIP_LOCKED)
|
||||
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, pending.getValue("id")));
|
||||
query.withPermissionBypass("Internal attachment lifecycle owns this immutable cleanup locator.");
|
||||
List<QRecord> rows = new QueryAction().execute(query).getRecords();
|
||||
return (rows.isEmpty() ? null : rows.getFirst());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Distinguish an active owner from a committed row requiring delayed retry.
|
||||
*******************************************************************************/
|
||||
private enum CleanupOutcome
|
||||
{
|
||||
COMPLETED, ACTIVE, REFERENCED, UNCERTAIN_UPLOAD
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hold the same row lock as an uploader until deletion and acknowledgement end.
|
||||
*******************************************************************************/
|
||||
private CleanupOutcome finish(QRecord pending) throws QException
|
||||
{
|
||||
if(!supportsUploadOwnership())
|
||||
{
|
||||
return (finishUnlocked(pending));
|
||||
}
|
||||
CleanupOutcome[] outcome = { CleanupOutcome.ACTIVE };
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
QContext.withTemporaryContext(context, () ->
|
||||
{
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new DeleteInput(CollaborationTables.ATTACHMENT_CLEANUP)))
|
||||
{
|
||||
QContext.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
QRecord locked = lock(pending, transaction);
|
||||
if(locked != null)
|
||||
{
|
||||
outcome[0] = finishUnlocked(locked);
|
||||
}
|
||||
transaction.commit();
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
transaction.rollback();
|
||||
throw (failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
}
|
||||
});
|
||||
return (outcome[0]);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never erase bytes while metadata still exists, including nontransactional hosts.
|
||||
** Provider or queue deletion failure retains the intent for idempotent replay.
|
||||
*******************************************************************************/
|
||||
private CleanupOutcome finishUnlocked(QRecord pending) throws QException
|
||||
{
|
||||
if(pending.getValue("resolvedAt") != null)
|
||||
{
|
||||
return (CleanupOutcome.COMPLETED);
|
||||
}
|
||||
if(!AttachmentRecords.query(new QQueryFilter()
|
||||
.withCriteria("bucketName", QCriteriaOperator.EQUALS, pending.getValue("bucketName"))
|
||||
.withCriteria("storageKey", QCriteriaOperator.EQUALS, pending.getValue("storageKey"))).isEmpty())
|
||||
{
|
||||
return (CleanupOutcome.REFERENCED);
|
||||
}
|
||||
if(pending.getValue("attachmentId") == null)
|
||||
{
|
||||
if(!storage.cleanupUpload(pending.getValueString("bucketName"), pending.getValueString("storageKey")))
|
||||
{
|
||||
return (CleanupOutcome.UNCERTAIN_UPLOAD);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
storage.delete(pending.getValueString("bucketName"), pending.getValueString("storageKey"));
|
||||
}
|
||||
DeleteInput delete = new DeleteInput(CollaborationTables.ATTACHMENT_CLEANUP).withPrimaryKeys(List.of(pending.getValue("id"))).withTransaction(QContext.getQBackendTransaction());
|
||||
delete.withPermissionBypass("Storage cleanup completed; remove its durable intent.");
|
||||
AttachmentRecords.checkErrors(new DeleteAction().execute(delete).getRecordsWithErrors());
|
||||
return (CleanupOutcome.COMPLETED);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rollback compensation must survive a provider outage. Persist independently
|
||||
** of the failed transaction, then attempt cleanup with the original context.
|
||||
*******************************************************************************/
|
||||
void compensateUpload(String bucket, String key)
|
||||
{
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () ->
|
||||
{
|
||||
InsertInput insert = new InsertInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(
|
||||
new QRecord().withValue("bucketName", bucket).withValue("storageKey", key)));
|
||||
insert.withPermissionBypass("Failed authorized upload retains its immutable storage locator for compensation.");
|
||||
List<QRecord> inserted = new InsertAction().execute(insert).getRecords();
|
||||
AttachmentRecords.checkErrors(inserted);
|
||||
finish(inserted.getFirst());
|
||||
});
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Upload compensation could not complete.", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded recovery from committed intents, independent of the original service.
|
||||
*******************************************************************************/
|
||||
public int retryPending() throws QException
|
||||
{
|
||||
requireOperator();
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new QException("Attachment cleanup must run outside a caller transaction."));
|
||||
}
|
||||
QueryInput query = new QueryInput(CollaborationTables.ATTACHMENT_CLEANUP).withFilter(new QQueryFilter()
|
||||
.withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK).withSubFilter(new QQueryFilter()
|
||||
.withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("nextAttemptAt", QCriteriaOperator.IS_BLANK)
|
||||
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now()))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(100));
|
||||
query.withPermissionBypass("Authorized operator retries committed storage cleanup.");
|
||||
List<QRecord> pending = new QueryAction().execute(query).getRecords();
|
||||
int completed = 0;
|
||||
QException failure = null;
|
||||
for(QRecord row : pending)
|
||||
{
|
||||
try
|
||||
{
|
||||
CleanupOutcome outcome = finish(row);
|
||||
if(outcome == CleanupOutcome.COMPLETED)
|
||||
{
|
||||
completed++;
|
||||
}
|
||||
else if(outcome == CleanupOutcome.REFERENCED || outcome == CleanupOutcome.UNCERTAIN_UPLOAD)
|
||||
{
|
||||
defer(row);
|
||||
}
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
try
|
||||
{
|
||||
defer(row);
|
||||
}
|
||||
catch(QException | RuntimeException deferFailure)
|
||||
{
|
||||
e.addSuppressed(deferFailure);
|
||||
}
|
||||
if(failure == null)
|
||||
{
|
||||
failure = new QException("Attachment cleanup remains pending; retry after the provider or database recovers.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(failure != null)
|
||||
{
|
||||
throw (failure);
|
||||
}
|
||||
return (completed);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist backoff so failing entries do not monopolize the next bounded batch.
|
||||
*******************************************************************************/
|
||||
private void defer(QRecord pending) throws QException
|
||||
{
|
||||
UpdateInput update = new UpdateInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(
|
||||
new QRecord().withValue("id", pending.getValue("id")).withValue("nextAttemptAt", Instant.now().plusSeconds(60))));
|
||||
update.withPermissionBypass("Committed cleanup failed or is not yet safe; retain it for a later attempt.");
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(update).getRecords());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An identified administrator attests provider settlement; absence or age alone
|
||||
** is not evidence. Keep the receipt on the service-managed row permanently.
|
||||
*******************************************************************************/
|
||||
public void resolveUpload(Serializable id, String evidence) throws QException
|
||||
{
|
||||
String actor = requireSettlementOperator();
|
||||
validateEvidence(evidence);
|
||||
if(id == null || QContext.getQBackendTransaction() != null || !supportsUploadOwnership())
|
||||
{
|
||||
throw (new QException("Upload settlement requires a record ID and an independent locking transaction."));
|
||||
}
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new UpdateInput(CollaborationTables.ATTACHMENT_CLEANUP)))
|
||||
{
|
||||
QContext.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
QRecord pending = lock(new QRecord().withValue("id", id), transaction);
|
||||
if(pending == null)
|
||||
{
|
||||
throw (new QException("Cleanup record is active or no longer exists. Retry after its owner finishes."));
|
||||
}
|
||||
if(pending.getValue("resolvedAt") != null)
|
||||
{
|
||||
transaction.commit();
|
||||
return;
|
||||
}
|
||||
if(pending.getValue("attachmentId") != null)
|
||||
{
|
||||
throw (new QException("Use normal cleanup retry for committed attachment deletions."));
|
||||
}
|
||||
if(!AttachmentRecords.query(new QQueryFilter()
|
||||
.withCriteria("bucketName", QCriteriaOperator.EQUALS, pending.getValue("bucketName"))
|
||||
.withCriteria("storageKey", QCriteriaOperator.EQUALS, pending.getValue("storageKey"))).isEmpty())
|
||||
{
|
||||
throw (new QException("An attachment still references this object; settlement is not allowed."));
|
||||
}
|
||||
storage.delete(pending.getValueString("bucketName"), pending.getValueString("storageKey"));
|
||||
UpdateInput update = new UpdateInput(CollaborationTables.ATTACHMENT_CLEANUP).withTransaction(transaction)
|
||||
.withRecords(List.of(new QRecord().withValue("id", pending.getValue("id"))
|
||||
.withValue("resolvedAt", Instant.now()).withValue("resolvedBy", actor)
|
||||
.withValue("settlementEvidence", evidence.strip())));
|
||||
update.withPermissionBypass("Identified collaboration administrator records confirmed upload settlement after storage cleanup.");
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(update).getRecords());
|
||||
transaction.commit();
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
transaction.rollback();
|
||||
throw (failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The form and service both reject missing or oversized evidence.
|
||||
*******************************************************************************/
|
||||
static void validateEvidence(String evidence) throws QException
|
||||
{
|
||||
if(evidence == null || evidence.isBlank() || evidence.strip().length() > 2000)
|
||||
{
|
||||
throw (new QException("Record provider completion or cancellation evidence (1–2000 characters)."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Settlement is a deliberate, attributable admin action, never scheduled work.
|
||||
*******************************************************************************/
|
||||
static String requireSettlementOperator() throws QPermissionDeniedException
|
||||
{
|
||||
var session = QContext.getQSession();
|
||||
String actor = CollaborationSecurity.userId(session);
|
||||
if(session == null || session instanceof QSystemUserSession || !session.hasPermission(CollaborationPermissions.ADMIN)
|
||||
|| actor == null || actor.isBlank() || actor.length() > 255)
|
||||
{
|
||||
throw (new QPermissionDeniedException("An identified collaboration administrator must confirm upload settlement."));
|
||||
}
|
||||
return (actor);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled system work and explicit admin recovery share one authorization rule.
|
||||
*******************************************************************************/
|
||||
static void requireOperator() throws QPermissionDeniedException
|
||||
{
|
||||
var session = QContext.getQSession();
|
||||
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(CollaborationPermissions.ADMIN)))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the collaboration.admin permission."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +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.collaboration.attachments;
|
||||
|
||||
/*******************************************************************************
|
||||
** Verified bytes returned only after authorization, integrity and audit checks.
|
||||
** Storage coordinates never cross this boundary. Content is bounded to 20 MiB.
|
||||
*******************************************************************************/
|
||||
public record AttachmentContent(Integer attachmentId, String fileName, String contentType, String sha256, byte[] bytes)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Own the byte array so a caller cannot change a retained receipt's content.
|
||||
*******************************************************************************/
|
||||
public AttachmentContent
|
||||
{
|
||||
if(bytes == null || bytes.length > 20 * 1024 * 1024)
|
||||
{
|
||||
throw (new IllegalArgumentException("Attachment content is missing or exceeds the supported limit."));
|
||||
}
|
||||
bytes = bytes.clone();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return caller-owned bytes without exposing the immutable content buffer.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public byte[] bytes()
|
||||
{
|
||||
return (bytes.clone());
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +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.collaboration.attachments;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact stored content failed its immutable metadata check. Consumers must not
|
||||
** retry this as a transport outage or release the unverified bytes.
|
||||
*******************************************************************************/
|
||||
public class AttachmentContentIntegrityException extends QException
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Fixed public diagnostic, with no object locator or provider details.
|
||||
*******************************************************************************/
|
||||
public AttachmentContentIntegrityException()
|
||||
{
|
||||
super("Attachment content failed integrity verification.");
|
||||
}
|
||||
}
|
||||
@@ -1,54 +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.collaboration.attachments;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.Objects;
|
||||
|
||||
/*******************************************************************************
|
||||
** A download destination may require the application's authenticated transport.
|
||||
** The URL is sensitive and never attests that bytes were served.
|
||||
*******************************************************************************/
|
||||
public record AttachmentDownload(URL url, boolean authenticated)
|
||||
{
|
||||
/***************************************************************************
|
||||
** Only ordinary HTTP destinations are eligible for attachment delivery.
|
||||
***************************************************************************/
|
||||
public AttachmentDownload
|
||||
{
|
||||
Objects.requireNonNull(url, "url");
|
||||
if((!"https".equalsIgnoreCase(url.getProtocol()) && !"http".equalsIgnoreCase(url.getProtocol()))
|
||||
|| url.getUserInfo() != null || url.getRef() != null || url.getHost() == null || url.getHost().isBlank())
|
||||
{
|
||||
throw new IllegalArgumentException("Attachment download requires HTTP or HTTPS");
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Never disclose storage signatures through ordinary object logging.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("AttachmentDownload[authenticated=" + authenticated + ", url=<redacted>]");
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,6 @@ import java.time.Instant;
|
||||
** Panel DTO. Storage locations and ephemeral signed URLs stay server-side.
|
||||
*******************************************************************************/
|
||||
public record AttachmentEntry(Integer id, String tableName, String recordId, String fileName, String contentType,
|
||||
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, Boolean canEdit)
|
||||
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve source compatibility for callers using the earlier DTO constructor.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry(Integer id, String tableName, String recordId, String fileName, String contentType,
|
||||
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete)
|
||||
{
|
||||
this(id, tableName, recordId, fileName, contentType, sizeBytes, sha256, description, createdByUserId, createDate, canDelete, canDelete);
|
||||
}
|
||||
}
|
||||
-214
@@ -1,214 +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.collaboration.attachments;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted host extension for attachment lifecycle policy. The default preserves
|
||||
** ordinary Collaboration behavior. Implementations must be concurrency-safe and
|
||||
** must not retain request context. Callbacks receive defensive record copies.
|
||||
** This is not an untrusted scripting API or a new file-storage abstraction.
|
||||
*******************************************************************************/
|
||||
public class AttachmentLifecyclePolicy
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Configuration-only capability policy, evaluated during metadata production
|
||||
** without a session. Dynamic parent visibility owners opt in to pinned counts.
|
||||
*******************************************************************************/
|
||||
public boolean requiresPinnedAggregateParent()
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An installed retention owner must authorize an exact archival reference
|
||||
** transfer in the caller's real transaction. Ordinary policies deny this.
|
||||
*******************************************************************************/
|
||||
public void beforeReferenceTransfer(QRecord attachment, String tableName, String recordId, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
throw (new QException("Retained reference transfer requires an archive policy."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist the reference-transfer outcome in the same transaction. The policy
|
||||
** must not commit or close the transaction; failure requires caller rollback.
|
||||
*******************************************************************************/
|
||||
public void afterReferenceTransfer(QRecord attachment, String tableName, String recordId, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
throw (new QException("Retained reference transfer requires an atomic archive audit policy."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called with canonical source, actual digest/size and persisted retention
|
||||
** decision immediately before the first object write. There is no row ID yet.
|
||||
** Retained originals require an independently durable intent before storage.
|
||||
*******************************************************************************/
|
||||
public void beforeStored(QRecord attachment) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained storage requires a durable write-intent policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recheck current adoption authority after external operations, without
|
||||
** creating another intent or granting access to the original's bytes.
|
||||
*******************************************************************************/
|
||||
public void authorizeAdoption(QRecord attachment) throws QException
|
||||
{
|
||||
throw (new QException("Existing-original adoption is not configured."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit adoption of an existing owner original, not a new upload. The
|
||||
** policy must authorize the current source and commit its intent before read.
|
||||
*******************************************************************************/
|
||||
public void beforeAdoption(QRecord attachment) throws QException
|
||||
{
|
||||
throw (new QException("Existing-original adoption requires an audited retention policy."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the retention transition in the owner's transaction. Registration
|
||||
** follows separately; failure after commit leaves a retained retryable row.
|
||||
*******************************************************************************/
|
||||
public void afterAdoption(QRecord attachment, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
throw (new QException("Existing-original adoption requires an atomic retention audit."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called after storage and metadata insertion, before upload returns. Failure
|
||||
** leaves the original intact for reconciliation; it is not a rollback hook.
|
||||
** Enforce incomplete registration/quarantine in beforeAccess as appropriate.
|
||||
*******************************************************************************/
|
||||
public void afterStored(QRecord attachment) throws QException
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Before reading an existing original for registration recovery. This is not
|
||||
** a write attempt or a user download; retained owners must audit it explicitly.
|
||||
*******************************************************************************/
|
||||
public void beforeRecovery(QRecord candidate) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(candidate.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained recovery requires a durable recovery-attempt policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called after parent authorization and before either preview or download URL
|
||||
** signing. Throw to deny. A successful audit here means access was requested,
|
||||
** not that bytes were downloaded. Mandatory audit must commit independently
|
||||
** before returning; the caller transaction may still roll back afterwards.
|
||||
*******************************************************************************/
|
||||
public void beforeAccess(QRecord attachment, boolean inline) throws QException
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional authenticated delivery after ordinary parent/lifecycle checks.
|
||||
** Return null to retain the existing storage URL. No alternate lookup occurs.
|
||||
*******************************************************************************/
|
||||
public AttachmentDownload downloadDelivery(QRecord attachment) throws QException
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorize and durably audit a server-side content read, distinct from URL
|
||||
** issuance. Retained content requires an implementation of this new contract.
|
||||
*******************************************************************************/
|
||||
public void beforeContentRead(QRecord attachment) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained content requires an audited content-read policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist the verified server-read result before content leaves the service.
|
||||
** This does not attest user download, printer spooling or physical output.
|
||||
*******************************************************************************/
|
||||
public void afterContentRead(QRecord attachment, Long bytesRead) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained content requires an audited read-result policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return true to prohibit deletion and preserve bytes on parent cleanup.
|
||||
** Called during intake before storage with complete metadata but no row id;
|
||||
** true is persisted on the attachment and cannot be cleared by this API.
|
||||
** It is also queried for legacy rows during deletion/read hints. Failure
|
||||
** denies deletion. Do not depend on parent existence or caller permissions.
|
||||
*******************************************************************************/
|
||||
public boolean isRetained(QRecord attachment) throws QException
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called after ownership/parent checks and before applying display metadata.
|
||||
** The second record contains id and proposed fields, not the complete row.
|
||||
** This records an intent only; it does not attest that a transaction committed.
|
||||
*******************************************************************************/
|
||||
public void beforeMetadataChange(QRecord attachment, QRecord changes) throws QException
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the actual owner transaction before applying display metadata.
|
||||
** Required providers must reject unsupported/no-op transactions and incompatible
|
||||
** audit backends before the first write. This method does not commit anything.
|
||||
*******************************************************************************/
|
||||
public void validateMetadataTransaction(QRecord attachment, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained metadata requires an atomic audit transaction policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the successful display metadata change in the supplied transaction
|
||||
** before its owner commits. Required providers must validate real transaction
|
||||
** and backend compatibility, insert their event using this transaction and
|
||||
** throw on failure. Never commit or close the supplied owner transaction.
|
||||
*******************************************************************************/
|
||||
public void afterMetadataChange(QRecord attachment, QRecord changes, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained metadata requires an atomic audit-result policy."));
|
||||
}
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +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.collaboration.attachments;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/*******************************************************************************
|
||||
** What an uploaded object is allowed to tell a browser. The declared media type
|
||||
** is client-controlled: it is kept as display metadata, but the object itself is
|
||||
** stored with an inert type and an inline disposition only when the type is a
|
||||
** known passive image, PDF, plain-text, audio or video format. Anything else (HTML, SVG,
|
||||
** XML, script, unknown) is stored as an opaque attachment, so a signed storage
|
||||
** link can never render or execute uploaded active content inline.
|
||||
*******************************************************************************/
|
||||
final class AttachmentMediaPolicy
|
||||
{
|
||||
static final String OPAQUE = "application/octet-stream";
|
||||
|
||||
private static final Set<String> PASSIVE_INLINE = Set.of(
|
||||
"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain",
|
||||
"audio/mpeg", "audio/mp4", "audio/ogg", "audio/wav", "audio/webm",
|
||||
"video/mp4", "video/webm", "video/ogg");
|
||||
|
||||
/***************************************************************************
|
||||
** Static policy only.
|
||||
***************************************************************************/
|
||||
private AttachmentMediaPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The base media type of a declared value: lowercase, without parameters.
|
||||
** Anything that is not a plain type/subtype token is treated as unknown.
|
||||
***************************************************************************/
|
||||
static String baseType(String declared)
|
||||
{
|
||||
if(declared == null)
|
||||
{
|
||||
return (OPAQUE);
|
||||
}
|
||||
String base = declared.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
|
||||
return (base.matches("[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}") ? base : OPAQUE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The media type the stored object will be served with.
|
||||
***************************************************************************/
|
||||
static String storedType(String declared)
|
||||
{
|
||||
String base = baseType(declared);
|
||||
return (PASSIVE_INLINE.contains(base) ? base : OPAQUE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Inline only for passive types; every other object is an attachment.
|
||||
***************************************************************************/
|
||||
static String disposition(String declared)
|
||||
{
|
||||
return (PASSIVE_INLINE.contains(baseType(declared)) ? "inline" : "attachment");
|
||||
}
|
||||
}
|
||||
+6
-13
@@ -46,16 +46,15 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
|
||||
{
|
||||
return (filter(records, input instanceof GetInput, input.getShouldGenerateDisplayValues(), new AttachmentsService()));
|
||||
return (filter(records, input instanceof GetInput, new AttachmentsService()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep filtering every row even when the preview generation cap is reached.
|
||||
*******************************************************************************/
|
||||
List<QRecord> filter(List<QRecord> records, boolean isGet, boolean generateUrls, AttachmentsService service) throws QException
|
||||
List<QRecord> filter(List<QRecord> records, boolean isGet, AttachmentsService service) throws QException
|
||||
{
|
||||
List<QRecord> visible = new ArrayList<>();
|
||||
ParentRecordAccess.ResultSetParents parents = new ParentRecordAccess.ResultSetParents();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
try
|
||||
@@ -64,7 +63,7 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
parents.requireVisible(record.getValueString("tableName"), record.getValueString("recordId"));
|
||||
ParentRecordAccess.requireVisibleParent(record.getValueString("tableName"), record.getValueString("recordId"));
|
||||
}
|
||||
catch(QNotFoundException expected)
|
||||
{
|
||||
@@ -78,21 +77,15 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
|
||||
QRecord row = new QRecord(record);
|
||||
row.setValue("previewUrl", null);
|
||||
row.setValue("downloadUrl", null);
|
||||
row.setValue("previewDelivery", null);
|
||||
row.setValue("downloadDelivery", null);
|
||||
row.withDisplayValue("downloadUrl", null).withDisplayValue("downloadUrl:downloadUrlDynamic", null);
|
||||
visible.add(row);
|
||||
if(generateUrls && visible.size() <= 200)
|
||||
if(visible.size() <= 200)
|
||||
{
|
||||
try
|
||||
{
|
||||
AttachmentDownload preview = service.delivery(row, true);
|
||||
AttachmentDownload download = service.delivery(row, false);
|
||||
row.setValue("previewUrl", preview.url().toString());
|
||||
row.setValue("previewDelivery", preview.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL");
|
||||
String downloadUrl = download.url().toString();
|
||||
row.setValue("previewUrl", service.url(row, true).toString());
|
||||
String downloadUrl = service.url(row, false).toString();
|
||||
row.setValue("downloadUrl", downloadUrl);
|
||||
row.setValue("downloadDelivery", download.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL");
|
||||
row.withDisplayValue("downloadUrl", row.getValueString("fileName"))
|
||||
.withDisplayValue("downloadUrl:downloadUrlDynamic", downloadUrl);
|
||||
}
|
||||
|
||||
@@ -22,17 +22,13 @@
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.objectstorage.api.DeleteMode;
|
||||
import com.kingsrook.qbits.objectstorage.api.DeleteRequest;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectStorage;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
|
||||
import com.kingsrook.qbits.objectstorage.api.PresignGet;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qbits.objectstorage.api.Range;
|
||||
import com.kingsrook.qbits.objectstorage.api.StorageBucket;
|
||||
import com.kingsrook.qbits.objectstorage.api.StoredObject;
|
||||
|
||||
/*******************************************************************************
|
||||
** Narrow storage boundary. The caller must check collaboration access first.
|
||||
@@ -66,22 +62,6 @@ public class AttachmentStorage
|
||||
bucket(name).put(request);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Open content for an already-authorized caller. The service owns closing it.
|
||||
*******************************************************************************/
|
||||
public ObjectStream open(String name, String key)
|
||||
{
|
||||
return (bucket(name).get(key, Range.all()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect a deterministic intake key after uncertain conditional creation.
|
||||
*******************************************************************************/
|
||||
public Optional<StoredObject> head(String name, String key)
|
||||
{
|
||||
return (bucket(name).head(key));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Prefer soft deletion; allow permanent deletion only where no soft mode exists.
|
||||
*******************************************************************************/
|
||||
@@ -93,18 +73,6 @@ public class AttachmentStorage
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove failed-upload bytes and report whether no earlier PUT can still finish.
|
||||
** Generic object storage cannot prove remote settlement after a timeout. Keep
|
||||
** its durable locator for later sweeps. Adapters may return true only with an
|
||||
** authoritative completion/cancellation guarantee, never from object absence.
|
||||
*******************************************************************************/
|
||||
public boolean cleanupUpload(String name, String key)
|
||||
{
|
||||
delete(name, key);
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a temporary link, never a persistent table value.
|
||||
*******************************************************************************/
|
||||
|
||||
+28
-657
@@ -21,8 +21,6 @@
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
@@ -39,17 +37,14 @@ import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
|
||||
import com.kingsrook.qbits.objectstorage.api.PresignGet;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
@@ -73,8 +68,6 @@ public class AttachmentsService
|
||||
{
|
||||
private final CollaborationQBitConfig config;
|
||||
private final AttachmentStorage storage;
|
||||
private final AttachmentLifecyclePolicy lifecycle;
|
||||
private final com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournal uploadJournal;
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the installed configuration without a global mutable singleton.
|
||||
@@ -84,46 +77,15 @@ public class AttachmentsService
|
||||
this.config = (CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
|
||||
.filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig();
|
||||
this.storage = com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader.getAdHoc(AttachmentStorage.class, config.getAttachmentStorage());
|
||||
this.lifecycle = resolveLifecycle(config);
|
||||
this.uploadJournal = new com.kingsrook.qbits.collaboration.attachments.uploadjournal.JdbcUploadJournal();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Injectable storage boundary for deterministic service tests.
|
||||
*******************************************************************************/
|
||||
public AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage)
|
||||
{
|
||||
this(config, storage, resolveLifecycle(config));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-local deterministic lifecycle seam for service tests.
|
||||
*******************************************************************************/
|
||||
AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle)
|
||||
{
|
||||
this(config, storage, lifecycle, new com.kingsrook.qbits.collaboration.attachments.uploadjournal.JdbcUploadJournal());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit test-only package seam; public services always use the durable
|
||||
** qualified journal. It cannot be replaced through application configuration.
|
||||
*******************************************************************************/
|
||||
AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle,
|
||||
com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournal uploadJournal)
|
||||
{
|
||||
this.config = config;
|
||||
this.storage = storage;
|
||||
this.lifecycle = java.util.Objects.requireNonNull(lifecycle, "lifecycle");
|
||||
this.uploadJournal = java.util.Objects.requireNonNull(uploadJournal, "uploadJournal");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid policy configuration must fail before any storage side effect.
|
||||
*******************************************************************************/
|
||||
private static AttachmentLifecyclePolicy resolveLifecycle(CollaborationQBitConfig config)
|
||||
{
|
||||
return (java.util.Objects.requireNonNull(com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader
|
||||
.getAdHoc(AttachmentLifecyclePolicy.class, config.getAttachmentLifecycle()), "Could not load attachment lifecycle policy."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -131,36 +93,6 @@ public class AttachmentsService
|
||||
** The caller owns the input stream. Oversize files never reach object storage.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry upload(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description) throws QException
|
||||
{
|
||||
return (uploadInternal(tableName, recordId, fileName, contentType, stream, description, null, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retryable intake for a server coordinator. Keys and manifests are SHA-256
|
||||
** identifiers; keys are additionally scoped to actual actor and parent here.
|
||||
** This path owns persistence and refuses ambient transactions. Replays verify
|
||||
** supplied bytes and rerun registration; they never create another original.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry upload(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description,
|
||||
String intakeKey, String manifestSha256) throws QException
|
||||
{
|
||||
if(intakeKey == null || !intakeKey.matches("[0-9a-f]{64}") || manifestSha256 == null || !manifestSha256.matches("[0-9a-f]{64}"))
|
||||
{
|
||||
throw (new QBadRequestException("Intake key and manifest must be SHA-256 identifiers."));
|
||||
}
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new QException("Retryable attachment intake cannot join an ambient transaction."));
|
||||
}
|
||||
return (uploadInternal(tableName, recordId, fileName, contentType, stream, description, intakeKey, manifestSha256));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared bounded intake; the retryable path preserves uncertain objects for
|
||||
** reconciliation under a deterministic key and never overwrites them.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry uploadInternal(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description,
|
||||
String intakeKey, String manifestSha256) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
ParentRecordAccess.requireFeature(tableName, recordId, "attach");
|
||||
@@ -168,19 +100,13 @@ public class AttachmentsService
|
||||
String id = canonicalId(tableName, recordId);
|
||||
String name = sanitize(fileName);
|
||||
String bucket = config.getAttachmentsBucketName();
|
||||
String scopedKey = intakeKey == null ? null : tupleHash("attachment-intake-v1", user, tableName, id, intakeKey);
|
||||
String key = scopedKey == null ? "attachments/" + segment(tableName) + "/" + segment(id) + "/" + UUID.randomUUID() + "/" + name
|
||||
: "attachments/intake/" + scopedKey;
|
||||
String key = "attachments/" + segment(tableName) + "/" + segment(id) + "/" + UUID.randomUUID() + "/" + name;
|
||||
if(tableName.length() > 128 || id.length() > 64 || user.length() > 255 || bucket.length() > 255 || key.length() > 1024
|
||||
|| (contentType != null && contentType.length() > 255))
|
||||
{
|
||||
throw (new QBadRequestException("Attachment metadata exceeds the supported field lengths."));
|
||||
}
|
||||
long limit = Math.min(config.getDefaultMaxBytes(), storage.maxBytes(bucket));
|
||||
if(scopedKey != null)
|
||||
{
|
||||
limit = Math.min(limit, 20L * 1024 * 1024);
|
||||
}
|
||||
Path temporary = null;
|
||||
try
|
||||
{
|
||||
@@ -203,69 +129,30 @@ public class AttachmentsService
|
||||
}
|
||||
}
|
||||
String type = contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType;
|
||||
QRecord row = new QRecord().withValue("tableName", tableName).withValue("recordId", id).withValue("fileName", name)
|
||||
.withValue("contentType", type).withValue("sizeBytes", size).withValue("sha256", HexFormat.of().formatHex(digest.digest()))
|
||||
.withValue("bucketName", bucket).withValue("storageKey", key).withValue("description", description)
|
||||
.withValue("createdByUserId", user).withValue("parentDeleted", false);
|
||||
row.withValue("retained", lifecycle.isRetained(new QRecord(row)));
|
||||
if(scopedKey != null)
|
||||
try(InputStream upload = Files.newInputStream(temporary))
|
||||
{
|
||||
String fingerprint = tupleHash("attachment-manifest-v1", manifestSha256, name, type, description, String.valueOf(size), row.getValueString("sha256"));
|
||||
row.withValue("intakeKey", scopedKey).withValue("intakeFingerprint", fingerprint);
|
||||
QRecord prior = findIntake(scopedKey);
|
||||
if(prior != null)
|
||||
{
|
||||
if(Boolean.TRUE.equals(prior.getValueBoolean("retained")) && uploadJournal.findByLocation(bucket, key).isPresent())
|
||||
{
|
||||
return (journaledUpload(row, temporary));
|
||||
}
|
||||
return (replayIntake(prior, fingerprint));
|
||||
}
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("retained")))
|
||||
{
|
||||
return (journaledUpload(row, temporary));
|
||||
}
|
||||
beforeObjectWrite(row);
|
||||
createIntakeObject(bucket, key, Files.readAllBytes(temporary), type, fingerprint);
|
||||
}
|
||||
else
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("retained")))
|
||||
{
|
||||
return (journaledUpload(row, temporary));
|
||||
}
|
||||
beforeObjectWrite(row);
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// ordinary uploads keep the transactional cleanup-queue path (355b06d) //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
return (storeUpload(row, temporary));
|
||||
storage.put(bucket, PutRequest.of(key, upload).withContentLength(size).withContentType(type).withContentDisposition("inline"));
|
||||
}
|
||||
boolean rowSaved = false;
|
||||
try
|
||||
{
|
||||
QRecord row = new QRecord().withValue("tableName", tableName).withValue("recordId", id).withValue("fileName", name)
|
||||
.withValue("contentType", type).withValue("sizeBytes", size).withValue("sha256", HexFormat.of().formatHex(digest.digest()))
|
||||
.withValue("bucketName", bucket).withValue("storageKey", key).withValue("description", description)
|
||||
.withValue("createdByUserId", user).withValue("parentDeleted", false);
|
||||
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withRecords(List.of(row));
|
||||
input.withPermissionBypass("AttachmentsService checked parent and attach permission before upload.");
|
||||
List<QRecord> records = new InsertAction().execute(input).getRecords();
|
||||
AttachmentRecords.checkErrors(records);
|
||||
rowSaved = true;
|
||||
lifecycle.afterStored(new QRecord(records.getFirst()));
|
||||
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(tableName, id, "FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, records.getFirst().getValueString("id"));
|
||||
return (entry(records.getFirst()));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
if(scopedKey != null)
|
||||
{
|
||||
QRecord prior = findIntake(scopedKey);
|
||||
if(!rowSaved && prior != null)
|
||||
{
|
||||
return (replayIntake(prior, row.getValueString("intakeFingerprint")));
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
try
|
||||
{
|
||||
if(!rowSaved && !Boolean.TRUE.equals(row.getValueBoolean("retained")))
|
||||
if(!rowSaved)
|
||||
{
|
||||
storage.delete(bucket, key);
|
||||
}
|
||||
@@ -297,317 +184,23 @@ public class AttachmentsService
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** On transactional locking backends, own a transaction only after spooling
|
||||
** the bounded input. An existing outer transaction retains the upload lock.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry storeUpload(QRecord row, Path temporary) throws QException, IOException
|
||||
{
|
||||
var transaction = QContext.getQBackendTransaction();
|
||||
if(transaction == null && AttachmentCleanupQueue.supportsUploadOwnership())
|
||||
{
|
||||
try(var owned = com.kingsrook.qqq.backend.core.actions.QBackendTransaction.openFor(new InsertInput(CollaborationTables.ATTACHMENT)))
|
||||
{
|
||||
QContext.setTransaction(owned);
|
||||
try
|
||||
{
|
||||
AttachmentEntry result = storeUpload(row, temporary);
|
||||
owned.commit();
|
||||
return (result);
|
||||
}
|
||||
catch(QException | IOException | RuntimeException failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
owned.rollback();
|
||||
}
|
||||
catch(QException rollbackFailure)
|
||||
{
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
if(transaction != null && !transaction.supportsRollbackCallbacks())
|
||||
{
|
||||
throw (new QException("Attachment uploads require a transaction with confirmed rollback support."));
|
||||
}
|
||||
String bucket = row.getValueString("bucketName");
|
||||
String key = row.getValueString("storageKey");
|
||||
AttachmentCleanupQueue cleanup = new AttachmentCleanupQueue(storage);
|
||||
QRecord pending = cleanup.beginUpload(bucket, key);
|
||||
try(InputStream upload = Files.newInputStream(temporary))
|
||||
{
|
||||
storage.put(bucket, PutRequest.of(key, upload).withContentLength(row.getValueLong("sizeBytes"))
|
||||
.withContentType(AttachmentMediaPolicy.storedType(row.getValueString("contentType")))
|
||||
.withContentDisposition(AttachmentMediaPolicy.disposition(row.getValueString("contentType"))));
|
||||
}
|
||||
catch(IOException | RuntimeException failure)
|
||||
{
|
||||
if(pending == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
cleanup.compensateUpload(bucket, key);
|
||||
}
|
||||
catch(RuntimeException cleanupFailure)
|
||||
{
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
if(transaction != null && pending == null)
|
||||
{
|
||||
var context = new com.kingsrook.qqq.backend.core.context.CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
transaction.addAfterRollbackCallback(() -> QContext.withTemporaryContext(context,
|
||||
() -> cleanup.compensateUpload(bucket, key)));
|
||||
}
|
||||
boolean rowSaved = false;
|
||||
try
|
||||
{
|
||||
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withRecords(List.of(row));
|
||||
input.withPermissionBypass("AttachmentsService checked parent and attach permission before upload.");
|
||||
List<QRecord> records = new InsertAction().execute(input).getRecords();
|
||||
AttachmentRecords.checkErrors(records);
|
||||
rowSaved = true;
|
||||
cleanup.completeUpload(pending);
|
||||
lifecycle.afterStored(new QRecord(records.getFirst()));
|
||||
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
"FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, records.getFirst().getValueString("id"));
|
||||
return (entry(records.getFirst()));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
if(!rowSaved && pending == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
cleanup.compensateUpload(bucket, key);
|
||||
}
|
||||
catch(RuntimeException cleanupFailure)
|
||||
{
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retained bytes cannot be written before durable intent or under an ambient
|
||||
** transaction whose later rollback could lose their metadata association.
|
||||
*******************************************************************************/
|
||||
private void beforeObjectWrite(QRecord row) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("retained")) && QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new QException("Retained attachment storage cannot join an ambient transaction."));
|
||||
}
|
||||
lifecycle.beforeStored(new QRecord(row));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retained originals use qualified bounded conditional creation and durable
|
||||
** owner recovery; ordinary uploads retain the established streaming behavior.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry journaledUpload(QRecord row, Path temporary) throws QException, IOException
|
||||
{
|
||||
if(row.getValueLong("sizeBytes") > JournaledAttachmentUpload.MAX_BYTES)
|
||||
{
|
||||
throw (new QBadRequestException("Retained uploads are limited to 20 MiB by the qualified conditional storage path."));
|
||||
}
|
||||
var receipt = new JournaledAttachmentUpload(storage, lifecycle, uploadJournal).store(row, Files.readAllBytes(temporary));
|
||||
QRecord stored = receipt.attachment();
|
||||
if(!receipt.alreadyComplete())
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(stored.getValueString("tableName"), stored.getValueString("recordId"),
|
||||
"FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, stored.getValueString("id"));
|
||||
}
|
||||
return (entry(stored));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Native confirmation discovery checks the configured current adoption policy.
|
||||
** No original bytes are read and no audit success is asserted during review.
|
||||
*******************************************************************************/
|
||||
public QRecord requireAdoptable(Integer attachmentId) throws QException
|
||||
{
|
||||
QRecord row = requireMutable(attachmentId);
|
||||
lifecycle.authorizeAdoption(new QRecord(row));
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Adopt this exact visible original without uploading, copying or deleting
|
||||
** storage. Registration retries retain the same owner attachment identity.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry adoptRetainedAttachment(Integer attachmentId) throws QException
|
||||
{
|
||||
new ExistingAttachmentAdoption(this, storage, lifecycle).adopt(attachmentId);
|
||||
return (entry(requireRow(attachmentId)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recover a visible retained upload without accepting bytes, locators or an
|
||||
** actor from the client. Original-actor and current-parent checks are mandatory.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry recoverRetainedUpload(Integer journalId) throws QException
|
||||
{
|
||||
return (entry(new JournaledAttachmentUpload(storage, lifecycle, uploadJournal).recover(journalId)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Conditional single-request puts retain the same original across retries.
|
||||
** Byte arrays deliberately avoid a multipart provider path whose conditional
|
||||
** completion support is not qualified. The caller enforces a 20 MiB limit.
|
||||
*******************************************************************************/
|
||||
private void createIntakeObject(String bucket, String key, byte[] bytes, String type, String fingerprint) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
storage.put(bucket, PutRequest.of(key, bytes).withContentLength((long) bytes.length).withContentType(AttachmentMediaPolicy.storedType(type))
|
||||
.withContentDisposition("attachment").withIfNoneMatch(true).withMetadata(Map.of("qqq-intake-fingerprint", fingerprint)));
|
||||
}
|
||||
catch(RuntimeException uncertain)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(storage.head(bucket, key).isEmpty())
|
||||
{
|
||||
throw (uncertain);
|
||||
}
|
||||
try(ObjectStream existing = storage.open(bucket, key))
|
||||
{
|
||||
byte[] original = existing.getInputStream().readNBytes(bytes.length + 1);
|
||||
if(existing.getHeaders() == null || existing.getHeaders().getUserMetadata() == null
|
||||
|| !fingerprint.equals(existing.getHeaders().getUserMetadata().get("qqq-intake-fingerprint")) || !MessageDigest.isEqual(original, bytes))
|
||||
{
|
||||
throw (new QException("An intake request cannot replace an existing original."));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(IOException | RuntimeException verificationFailure)
|
||||
{
|
||||
throw (new QException("Could not reconcile attachment intake storage.", verificationFailure));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal lookup is keyed by authenticated actor and canonical parent scope.
|
||||
*******************************************************************************/
|
||||
private QRecord findIntake(String scopedKey) throws QException
|
||||
{
|
||||
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter().withCriteria("intakeKey", QCriteriaOperator.EQUALS, scopedKey).withLimit(1));
|
||||
return (rows.isEmpty() ? null : rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reauthorize persisted identity and retry idempotent owner registration.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry replayIntake(QRecord row, String fingerprint) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("parentDeleted")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
|
||||
if(!fingerprint.equals(row.getValueString("intakeFingerprint")))
|
||||
{
|
||||
throw (new QBadRequestException("Intake request conflicts with its original manifest."));
|
||||
}
|
||||
lifecycle.afterStored(new QRecord(row));
|
||||
return (entry(row));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Length-prefix all tuple members, including null, so delimiters cannot collide.
|
||||
*******************************************************************************/
|
||||
private String tupleHash(String... values) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try(DataOutputStream output = new DataOutputStream(buffer))
|
||||
{
|
||||
for(String value : values)
|
||||
{
|
||||
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
|
||||
output.writeInt(value == null ? -1 : bytes.length);
|
||||
output.write(bytes);
|
||||
}
|
||||
}
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(buffer.toByteArray())));
|
||||
}
|
||||
catch(IOException | NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new QException("Could not identify attachment intake.", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Newest first, independently of collaboration.view.
|
||||
*******************************************************************************/
|
||||
public List<AttachmentEntry> list(String tableName, Serializable recordId) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
String id = canonicalId(tableName, recordId);
|
||||
List<AttachmentEntry> entries = new ArrayList<>();
|
||||
for(QRecord row : AttachmentRecords.query(listFilter(tableName, recordId)))
|
||||
for(QRecord row : AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName)
|
||||
.withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("parentDeleted", QCriteriaOperator.NOT_EQUALS, true)
|
||||
.withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false))))
|
||||
{
|
||||
entries.add(entry(row));
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard Files-tab pages use one bounded native query, with one same-parent
|
||||
** lookahead row. Authorization is current on every request; offsets are not
|
||||
** snapshots across concurrent insertions or deletions.
|
||||
*******************************************************************************/
|
||||
public AttachmentPage listPage(String tableName, Serializable recordId, int offset, int limit) throws QException
|
||||
{
|
||||
if(offset < 0 || limit < 1 || limit > 100)
|
||||
{
|
||||
throw (new QBadRequestException("Offset must be nonnegative and limit must be between 1 and 100."));
|
||||
}
|
||||
List<QRecord> rows = AttachmentRecords.query(listFilter(tableName, recordId).withSkip(offset).withLimit(limit + 1));
|
||||
List<AttachmentEntry> entries = new ArrayList<>();
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
// Preserve the same metadata-policy evaluation for the lookahead row.
|
||||
AttachmentEntry visible = entry(row);
|
||||
if(entries.size() < limit)
|
||||
{
|
||||
entries.add(visible);
|
||||
}
|
||||
}
|
||||
return (new AttachmentPage(List.copyOf(entries), rows.size() > limit));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only authorized display metadata and continuation, never a raw total.
|
||||
*******************************************************************************/
|
||||
public record AttachmentPage(List<AttachmentEntry> items, boolean hasMore)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared canonical source authority, deletion filter and deterministic order.
|
||||
*******************************************************************************/
|
||||
private QQueryFilter listFilter(String tableName, Serializable recordId) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
String id = canonicalId(tableName, recordId);
|
||||
return (new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName)
|
||||
.withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("parentDeleted", QCriteriaOperator.NOT_EQUALS, true)
|
||||
.withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-check the attachment's actual parent before signing a five-minute GET.
|
||||
** Inline uses the stored inline header; RC.2 only overrides attachment names.
|
||||
@@ -618,106 +211,17 @@ public class AttachmentsService
|
||||
return (url(requireRow(attachmentId), inline));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard download clients preserve the selected delivery authentication mode.
|
||||
*******************************************************************************/
|
||||
public AttachmentDownload downloadDelivery(Serializable attachmentId) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
return (downloadDelivery(attachmentId, false));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve the explicit transport mode for preview and download consumers.
|
||||
***************************************************************************/
|
||||
public AttachmentDownload downloadDelivery(Serializable attachmentId, boolean inline) throws QException
|
||||
{
|
||||
return (delivery(requireRow(attachmentId), inline));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a bounded exact original for trusted integrations such as printing.
|
||||
** Authorize the actual parent, enforce lifecycle policy and verify persisted
|
||||
** length and SHA-256 before releasing any bytes. No signed URL is involved.
|
||||
*******************************************************************************/
|
||||
public AttachmentContent readContent(Serializable attachmentId, Integer maximumBytes) throws QException
|
||||
{
|
||||
QRecord row = requireRow(attachmentId);
|
||||
requireUser();
|
||||
requireRetainedPolicy(row);
|
||||
Long size = row.getValueLong("sizeBytes");
|
||||
String sha256 = row.getValueString("sha256");
|
||||
if(maximumBytes == null || maximumBytes < 1 || maximumBytes > 20 * 1024 * 1024)
|
||||
{
|
||||
throw (new QBadRequestException("Content read limit must be between 1 byte and 20 MiB."));
|
||||
}
|
||||
if(size == null || size < 0 || size > maximumBytes || sha256 == null || !sha256.matches("[0-9a-f]{64}"))
|
||||
{
|
||||
throw (new QBadRequestException("Attachment exceeds the content read limit or lacks integrity metadata."));
|
||||
}
|
||||
lifecycle.beforeContentRead(new QRecord(row));
|
||||
byte[] bytes;
|
||||
try(ObjectStream content = storage.open(row.getValueString("bucketName"), row.getValueString("storageKey")))
|
||||
{
|
||||
if(content.getContentLength() != null && !size.equals(content.getContentLength()))
|
||||
{
|
||||
throw (new AttachmentContentIntegrityException());
|
||||
}
|
||||
bytes = content.getInputStream().readNBytes(size.intValue() + 1);
|
||||
String actualHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
if(bytes.length != size.longValue() || !sha256.equals(actualHash))
|
||||
{
|
||||
throw (new AttachmentContentIntegrityException());
|
||||
}
|
||||
}
|
||||
catch(IOException | NoSuchAlgorithmException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not read attachment content.", e));
|
||||
}
|
||||
lifecycle.afterContentRead(new QRecord(row), size);
|
||||
return (new AttachmentContent(row.getValueInteger("id"), row.getValueString("fileName"), row.getValueString("contentType"), sha256, bytes));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sign an already-authorized row, also used by the preview customizer.
|
||||
*******************************************************************************/
|
||||
URL url(QRecord row, boolean inline) throws QException
|
||||
URL url(QRecord row, boolean inline)
|
||||
{
|
||||
return (delivery(row, inline).url());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve only the configured owner policy, after exact row authorization.
|
||||
*******************************************************************************/
|
||||
AttachmentDownload delivery(QRecord row, boolean inline) throws QException
|
||||
{
|
||||
requireRetainedPolicy(row);
|
||||
try
|
||||
{
|
||||
lifecycle.beforeAccess(new QRecord(row), inline);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// Preview generation may ignore storage failures, never policy failures.
|
||||
throw (new QException("Attachment access policy failed.", e));
|
||||
}
|
||||
AttachmentDownload configured = lifecycle.downloadDelivery(new QRecord(row));
|
||||
if(configured != null)
|
||||
{
|
||||
return (configured);
|
||||
}
|
||||
PresignGet request = PresignGet.of(row.getValueString("storageKey")).ttl(Duration.ofMinutes(5));
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Objects stored before AttachmentMediaPolicy keep their original headers (possibly an //
|
||||
// active type served inline). The link therefore forces an attachment disposition for //
|
||||
// every type that is not a known passive format, whatever the caller asked for; passive //
|
||||
// previews stay inline. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(!inline || !"inline".equals(AttachmentMediaPolicy.disposition(row.getValueString("contentType"))))
|
||||
if(!inline)
|
||||
{
|
||||
request.downloadAs(row.getValueString("fileName"));
|
||||
}
|
||||
return (new AttachmentDownload(storage.presign(row.getValueString("bucketName"), request), false));
|
||||
return (storage.presign(row.getValueString("bucketName"), request));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -729,7 +233,7 @@ public class AttachmentsService
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
for(Serializable id : attachmentIds.stream().distinct().toList())
|
||||
{
|
||||
rows.add(requireDeletable(id));
|
||||
rows.add(requireMutable(id));
|
||||
}
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
@@ -746,7 +250,6 @@ public class AttachmentsService
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
QRecord row = requireMutable(attachmentId);
|
||||
requireRetainedPolicy(row);
|
||||
QRecord change = new QRecord().withValue("id", row.getValue("id"));
|
||||
if(fileName != null)
|
||||
{
|
||||
@@ -756,41 +259,10 @@ public class AttachmentsService
|
||||
{
|
||||
change.setValue("description", description);
|
||||
}
|
||||
lifecycle.beforeMetadataChange(new QRecord(row), new QRecord(change));
|
||||
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withRecords(List.of(change));
|
||||
input.setTableMetaData(AttachmentRecords.storageTable());
|
||||
input.withPermissionBypass("AttachmentsService checked the actual parent and author/admin before metadata update.");
|
||||
QBackendTransaction callerTransaction = QContext.getQBackendTransaction();
|
||||
try(QBackendTransaction ownedTransaction = callerTransaction == null ? QBackendTransaction.openFor(input) : null)
|
||||
{
|
||||
QBackendTransaction transaction = ownedTransaction == null ? callerTransaction : ownedTransaction;
|
||||
input.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
lifecycle.validateMetadataTransaction(new QRecord(row), transaction);
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
|
||||
lifecycle.afterMetadataChange(new QRecord(row), new QRecord(change), transaction);
|
||||
if(ownedTransaction != null)
|
||||
{
|
||||
ownedTransaction.commit();
|
||||
}
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
if(ownedTransaction != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
ownedTransaction.rollback();
|
||||
}
|
||||
catch(QException rollbackFailure)
|
||||
{
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
}
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
|
||||
return (entry(requireRow(attachmentId)));
|
||||
}
|
||||
|
||||
@@ -802,46 +274,6 @@ public class AttachmentsService
|
||||
return (rename(attachmentId, null, description));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transfer a retained attachment's reference to an authorized archive parent.
|
||||
** The caller owns the transaction and must roll it back on any failure. Bytes,
|
||||
** hashes, author, storage coordinates and the retained flag never change.
|
||||
*******************************************************************************/
|
||||
public void transferRetainedReference(Serializable attachmentId, String tableName, Serializable recordId, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
requireUser();
|
||||
if(transaction == null || (QContext.getQBackendTransaction() != null && QContext.getQBackendTransaction() != transaction))
|
||||
{
|
||||
throw (new QException("Archive transfer requires the caller's explicit transaction."));
|
||||
}
|
||||
QRecord row = requireRow(attachmentId);
|
||||
if(!Boolean.TRUE.equals(row.getValueBoolean("retained")) || tableName == null || recordId == null)
|
||||
{
|
||||
throw (new QException("Only a retained attachment can be transferred to an archive."));
|
||||
}
|
||||
var candidate = uploadJournal.findByLocation(row.getValueString("bucketName"), row.getValueString("storageKey"));
|
||||
if(candidate.isPresent() && candidate.get().state() != com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalState.COMPLETE)
|
||||
{
|
||||
throw (new QException("Retained upload registration must complete before archival reference transfer."));
|
||||
}
|
||||
QRecord target = com.kingsrook.qqq.backend.core.actions.collaboration.RecordReferenceSecurityHelper.visibleParent(tableName, recordId, transaction);
|
||||
if(target == null)
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
String canonical = target.getValueString(QContext.getQInstance().getTable(tableName).getPrimaryKeyField());
|
||||
lifecycle.beforeReferenceTransfer(new QRecord(row), tableName, canonical, transaction);
|
||||
QRecord change = new QRecord().withValue("id", row.getValue("id")).withValue("tableName", tableName).withValue("recordId", canonical);
|
||||
com.kingsrook.qqq.backend.core.actions.collaboration.RecordReferenceSecurityHelper.copyMirroredLocks(
|
||||
QContext.getQInstance().getTable(tableName), target, QContext.getQInstance().getTable(CollaborationTables.ATTACHMENT), change);
|
||||
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT).withTransaction(transaction)
|
||||
.withRecords(List.of(change));
|
||||
input.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
|
||||
input.withPermissionBypass("Installed retention owner authorized this exact archival reference transfer and transaction.");
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
|
||||
lifecycle.afterReferenceTransfer(new QRecord(row), tableName, canonical, transaction);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Process Review uses persisted identity rather than trusting client rows.
|
||||
*******************************************************************************/
|
||||
@@ -850,7 +282,7 @@ public class AttachmentsService
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
QRecord row = requireRow(attachmentId);
|
||||
requireUser();
|
||||
if(!canModify(row))
|
||||
if(!canDelete(row))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Only the author or collaboration.admin can change this attachment."));
|
||||
}
|
||||
@@ -859,17 +291,7 @@ public class AttachmentsService
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Deletion reviews must include retention as well as parent and ownership.
|
||||
*******************************************************************************/
|
||||
public QRecord requireDeletable(Serializable attachmentId) throws QException
|
||||
{
|
||||
QRecord row = requireMutable(attachmentId);
|
||||
requireNotRetained(row);
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Atomically remove metadata and retain a durable storage cleanup request.
|
||||
** Delete storage first so a provider failure leaves a retryable row.
|
||||
*******************************************************************************/
|
||||
void deleteStoredRow(QRecord row) throws QException
|
||||
{
|
||||
@@ -881,20 +303,11 @@ public class AttachmentsService
|
||||
*******************************************************************************/
|
||||
void deleteStoredRow(QRecord row, boolean cleanup) throws QException
|
||||
{
|
||||
if(isRetained(row))
|
||||
{
|
||||
if(!cleanup)
|
||||
{
|
||||
throw (new QPermissionDeniedException("This attachment is retained and cannot be deleted."));
|
||||
}
|
||||
UpdateInput mark = new UpdateInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction())
|
||||
.withRecords(List.of(new QRecord().withValue("id", row.getValue("id")).withValue("parentDeleted", true)));
|
||||
mark.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
|
||||
mark.withPermissionBypass("Trusted parent cleanup retains the original and marks its parent deleted.");
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(mark).getRecords());
|
||||
return;
|
||||
}
|
||||
new AttachmentCleanupQueue(storage).delete(row, cleanup);
|
||||
storage.delete(row.getValueString("bucketName"), row.getValueString("storageKey"));
|
||||
DeleteInput input = new DeleteInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withPrimaryKeys(List.of(row.getValue("id")));
|
||||
input.setTableMetaData(cleanup ? com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT) : AttachmentRecords.storageTable());
|
||||
input.withPermissionBypass("Attachment service authorized deletion, or trusted parent cleanup selected this row.");
|
||||
AttachmentRecords.checkErrors(new DeleteAction().execute(input).getRecordsWithErrors());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -923,7 +336,7 @@ public class AttachmentsService
|
||||
/*******************************************************************************
|
||||
** Ownership is layered on parent visibility; admins still need that visibility.
|
||||
*******************************************************************************/
|
||||
private boolean canModify(QRecord row)
|
||||
private boolean canDelete(QRecord row)
|
||||
{
|
||||
String user = CollaborationSecurity.userId(QContext.getQSession());
|
||||
return (user != null && (isAdmin() || user.equals(row.getValueString("createdByUserId"))));
|
||||
@@ -953,53 +366,11 @@ public class AttachmentsService
|
||||
/*******************************************************************************
|
||||
** Return display data, without internal storage coordinates.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry entry(QRecord row) throws QException
|
||||
private AttachmentEntry entry(QRecord row)
|
||||
{
|
||||
boolean canEdit = canModify(row) && !(Boolean.TRUE.equals(row.getValueBoolean("retained")) && lifecycle.getClass().equals(AttachmentLifecyclePolicy.class));
|
||||
if(canEdit)
|
||||
{
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
|
||||
}
|
||||
catch(QPermissionDeniedException e)
|
||||
{
|
||||
canEdit = false;
|
||||
}
|
||||
}
|
||||
return (new AttachmentEntry(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
row.getValueString("fileName"), row.getValueString("contentType"), row.getValueLong("sizeBytes"), row.getValueString("sha256"),
|
||||
row.getValueString("description"), row.getValueString("createdByUserId"), row.getValueInstant("createDate"), canEdit && !isRetained(row), canEdit));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preflight every selected row before deleting any storage object.
|
||||
*******************************************************************************/
|
||||
private void requireNotRetained(QRecord row) throws QException
|
||||
{
|
||||
if(isRetained(row))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This attachment is retained and cannot be deleted."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persisted retention survives removal or reconfiguration of the provider.
|
||||
*******************************************************************************/
|
||||
private boolean isRetained(QRecord row) throws QException
|
||||
{
|
||||
return (Boolean.TRUE.equals(row.getValueBoolean("retained")) || lifecycle.isRetained(new QRecord(row)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Removing the configured provider must not permit unaudited retained access.
|
||||
*******************************************************************************/
|
||||
private void requireRetainedPolicy(QRecord row) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("retained")) && lifecycle.getClass().equals(AttachmentLifecyclePolicy.class))
|
||||
{
|
||||
throw (new QException("Retained attachment access requires its lifecycle policy."));
|
||||
}
|
||||
row.getValueString("description"), row.getValueString("createdByUserId"), row.getValueInstant("createDate"), canDelete(row)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@ public final class DeleteAttachmentsProcess
|
||||
{
|
||||
try
|
||||
{
|
||||
QRecord checked = service().requireDeletable(row.getValue("id"));
|
||||
QRecord checked = service().requireMutable(row.getValue("id"));
|
||||
output.getRecords().add(checked);
|
||||
selected.incrementCountAndAddPrimaryKey(checked.getValue("id"));
|
||||
}
|
||||
|
||||
-291
@@ -1,291 +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.collaboration.attachments;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
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.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner-only adoption of existing bytes. A durable intent precedes verification;
|
||||
** retention and its audit commit together, then idempotent registration follows.
|
||||
*******************************************************************************/
|
||||
final class ExistingAttachmentAdoption
|
||||
{
|
||||
private static final long MAX_BYTES = 20L * 1024 * 1024;
|
||||
private final AttachmentsService service;
|
||||
private final AttachmentStorage storage;
|
||||
private final AttachmentLifecyclePolicy lifecycle;
|
||||
|
||||
/***************************************************************************
|
||||
** Dependencies are the actual configured owner boundaries.
|
||||
***************************************************************************/
|
||||
ExistingAttachmentAdoption(AttachmentsService service, AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle)
|
||||
{
|
||||
this.service = service;
|
||||
this.storage = storage;
|
||||
this.lifecycle = lifecycle;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** No external transaction, caller locator, bytes, receipt or force option.
|
||||
***************************************************************************/
|
||||
void adopt(Integer id) throws QException
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
var session = QContext.getQSession();
|
||||
String actor = CollaborationSecurity.userId(session);
|
||||
Check check = () ->
|
||||
{
|
||||
if(QContext.getQBackendTransaction() != null || QContext.getQInstance() != instance || QContext.getQSession() != session
|
||||
|| actor == null || !actor.equals(CollaborationSecurity.userId(session)))
|
||||
{
|
||||
throw (new QException("Adoption requires the unchanged initiating context and its own transaction."));
|
||||
}
|
||||
};
|
||||
check.run();
|
||||
QRecord original = service.requireMutable(id);
|
||||
if(original.getValueString("intakeKey") != null)
|
||||
{
|
||||
throw (new QException("Coordinated intake must recover through its original request."));
|
||||
}
|
||||
requireIntegrity(original);
|
||||
lifecycle.authorizeAdoption(new QRecord(original));
|
||||
check.run();
|
||||
lifecycle.beforeAdoption(new QRecord(original));
|
||||
check.run();
|
||||
same(original, service.requireMutable(id));
|
||||
QRecord retained = new QRecord(original).withValue("retained", true);
|
||||
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT)
|
||||
.withRecords(List.of(new QRecord().withValue("id", id).withValue("retained", true)));
|
||||
input.setTableMetaData(AttachmentRecords.storageTable());
|
||||
input.withPermissionBypass("Attachment owner rechecked current parent, author/admin and audited retention policy.");
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(input))
|
||||
{
|
||||
try
|
||||
{
|
||||
lifecycle.validateMetadataTransaction(retained, transaction);
|
||||
check.run();
|
||||
lock(transaction, id);
|
||||
same(original, read(transaction, id));
|
||||
verify(original);
|
||||
check.run();
|
||||
same(original, service.requireMutable(id));
|
||||
lifecycle.authorizeAdoption(new QRecord(original));
|
||||
check.run();
|
||||
if(!Boolean.TRUE.equals(original.getValueBoolean("retained")))
|
||||
{
|
||||
input.setTransaction(transaction);
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
|
||||
lifecycle.afterAdoption(retained, transaction);
|
||||
}
|
||||
check.run();
|
||||
service.requireMutable(id);
|
||||
lifecycle.authorizeAdoption(new QRecord(retained));
|
||||
check.run();
|
||||
transaction.commit();
|
||||
}
|
||||
catch(QException | RuntimeException | Error failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
transaction.rollback();
|
||||
}
|
||||
catch(QException | RuntimeException cleanup)
|
||||
{
|
||||
if(cleanup != failure)
|
||||
{
|
||||
failure.addSuppressed(cleanup);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
check.run();
|
||||
same(retained, service.requireMutable(id));
|
||||
lifecycle.authorizeAdoption(new QRecord(retained));
|
||||
check.run();
|
||||
lifecycle.afterStored(new QRecord(retained));
|
||||
check.run();
|
||||
same(retained, service.requireMutable(id));
|
||||
lifecycle.authorizeAdoption(new QRecord(retained));
|
||||
check.run();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Native metadata supplies identifiers; no caller text becomes SQL.
|
||||
***************************************************************************/
|
||||
private void lock(QBackendTransaction transaction, Integer id) throws QException
|
||||
{
|
||||
if(!(transaction instanceof RDBMSTransaction jdbc))
|
||||
{
|
||||
throw (new QException("Existing-original adoption requires a real relational transaction."));
|
||||
}
|
||||
var table = AttachmentRecords.storageTable();
|
||||
String tableName = table.getBackendDetails() instanceof RDBMSTableBackendDetails details ? details.getTableName() : table.getName();
|
||||
String key = table.getField(table.getPrimaryKeyField()).getBackendName();
|
||||
key = key == null ? table.getPrimaryKeyField() : key;
|
||||
if(tableName == null || !tableName.matches("[A-Za-z_][A-Za-z0-9_]*") || !key.matches("[A-Za-z_][A-Za-z0-9_]*"))
|
||||
{
|
||||
throw (new QException("Unsupported attachment storage mapping."));
|
||||
}
|
||||
try
|
||||
{
|
||||
String quote = jdbc.getConnection().getMetaData().getIdentifierQuoteString().trim();
|
||||
if(!List.of("\"", "`").contains(quote))
|
||||
{
|
||||
throw (new QException("Unsupported attachment identifier quoting."));
|
||||
}
|
||||
try(var statement = jdbc.getConnection().prepareStatement("SELECT " + quote + key + quote + " FROM " + quote + tableName + quote
|
||||
+ " WHERE " + quote + key + quote + " = ? FOR UPDATE"))
|
||||
{
|
||||
statement.setQueryTimeout(5);
|
||||
statement.setObject(1, id);
|
||||
try(var result = statement.executeQuery())
|
||||
{
|
||||
if(!result.next())
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(java.sql.SQLException failure)
|
||||
{
|
||||
throw (new QException("Could not lock the original attachment.", failure));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Read the locked original in the same transaction without URL customizers.
|
||||
***************************************************************************/
|
||||
private QRecord read(QBackendTransaction transaction, Integer id) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(CollaborationTables.ATTACHMENT).withTransaction(transaction).withShouldFetchHeavyFields(true)
|
||||
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id).withLimit(1));
|
||||
input.setTableMetaData(AttachmentRecords.storageTable());
|
||||
input.withPermissionBypass("Owner adoption already authorized the exact parent and attachment.");
|
||||
var rows = new QueryAction().execute(input).getRecords();
|
||||
if(rows.size() != 1)
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Compare persisted metadata, allowing only the native modification timestamp.
|
||||
** The caller supplies the exact expected retention state at each boundary.
|
||||
***************************************************************************/
|
||||
private void same(QRecord expected, QRecord actual) throws QException
|
||||
{
|
||||
for(var field : AttachmentRecords.storageTable().getFields().keySet())
|
||||
{
|
||||
if(!"modifyDate".equals(field) && !Objects.equals(expected.getValue(field), actual.getValue(field)))
|
||||
{
|
||||
throw (new QException("Attachment metadata changed during adoption."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Reject ambiguous or oversized originals before opening storage.
|
||||
***************************************************************************/
|
||||
private void requireIntegrity(QRecord row) throws QException
|
||||
{
|
||||
Long size = row.getValueLong("sizeBytes");
|
||||
if(size == null || size < 0 || size > MAX_BYTES || row.getValueString("sha256") == null
|
||||
|| !row.getValueString("sha256").matches("[0-9a-f]{64}"))
|
||||
{
|
||||
throw (new QException("Original integrity metadata is missing or outside the adoption bound."));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Stream and hash the existing original without releasing or writing bytes.
|
||||
***************************************************************************/
|
||||
private void verify(QRecord row) throws QException
|
||||
{
|
||||
try(ObjectStream object = storage.open(row.getValueString("bucketName"), row.getValueString("storageKey")))
|
||||
{
|
||||
long expected = row.getValueLong("sizeBytes");
|
||||
if(object.getContentLength() != null && object.getContentLength() != expected)
|
||||
{
|
||||
throw (new AttachmentContentIntegrityException());
|
||||
}
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] buffer = new byte[8192];
|
||||
long total = 0;
|
||||
int count;
|
||||
while((count = object.getInputStream().read(buffer, 0, (int) Math.min(buffer.length, expected - total + 1))) != -1)
|
||||
{
|
||||
total += count;
|
||||
if(total > expected)
|
||||
{
|
||||
throw (new AttachmentContentIntegrityException());
|
||||
}
|
||||
digest.update(buffer, 0, count);
|
||||
}
|
||||
if(total != expected || !row.getValueString("sha256").equals(HexFormat.of().formatHex(digest.digest())))
|
||||
{
|
||||
throw (new AttachmentContentIntegrityException());
|
||||
}
|
||||
}
|
||||
catch(IOException | NoSuchAlgorithmException | RuntimeException failure)
|
||||
{
|
||||
throw (new QException("Could not verify the existing original.", failure));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Checked context guard without installing a different session or authority.
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Check
|
||||
{
|
||||
/***********************************************************************
|
||||
** Recheck the initiating context after external callbacks.
|
||||
***********************************************************************/
|
||||
void run() throws QException;
|
||||
}
|
||||
}
|
||||
-305
@@ -1,305 +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.collaboration.attachments;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadCandidate;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadEntry;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournal;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalState;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadReservation;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadStorageState;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
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.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retained-only coordination of one immutable original and its durable owner
|
||||
** manifest. All database ownership stays in Collaboration; lifecycle registration
|
||||
** may commit in its own module after the attachment checkpoint is durable.
|
||||
*******************************************************************************/
|
||||
final class JournaledAttachmentUpload
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The caller can avoid repeating non-mandatory timeline activity on replay.
|
||||
*******************************************************************************/
|
||||
record Receipt(QRecord attachment, boolean alreadyComplete) {}
|
||||
|
||||
static final long MAX_BYTES = 20L * 1024 * 1024;
|
||||
private static final Duration LEASE = Duration.ofMinutes(5);
|
||||
private final AttachmentStorage storage;
|
||||
private final AttachmentLifecyclePolicy lifecycle;
|
||||
private final UploadJournal journal;
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit owner dependencies; the journal never receives file bytes.
|
||||
*******************************************************************************/
|
||||
JournaledAttachmentUpload(AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle, UploadJournal journal)
|
||||
{
|
||||
this.storage = storage;
|
||||
this.lifecycle = lifecycle;
|
||||
this.journal = journal;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist intent before any PUT. Conditional creation never replaces an
|
||||
** original. A late or expired PUT cannot authorize metadata registration.
|
||||
*******************************************************************************/
|
||||
Receipt store(QRecord row, byte[] bytes) throws QException
|
||||
{
|
||||
requireIndependent();
|
||||
UploadCandidate candidate = candidate(row);
|
||||
authorize(candidate);
|
||||
if(bytes.length > MAX_BYTES || bytes.length != candidate.sizeBytes() || !hash(bytes).equals(candidate.sha256()))
|
||||
{
|
||||
throw (new QException("Retained original exceeds the qualified bound or changed before storage."));
|
||||
}
|
||||
UploadEntry entry = journal.prepare(candidate);
|
||||
if(entry.state() == UploadJournalState.COMPLETE)
|
||||
{
|
||||
QRecord existing = stored(entry);
|
||||
lifecycle.afterStored(new QRecord(existing));
|
||||
return (new Receipt(existing, true));
|
||||
}
|
||||
UploadReservation reservation = journal.reserve(entry.id(), LEASE);
|
||||
try
|
||||
{
|
||||
lifecycle.beforeStored(new QRecord(row));
|
||||
reservation = journal.renew(reservation, LEASE);
|
||||
if(entry.storageState() != UploadStorageState.VERIFIED)
|
||||
{
|
||||
journal.recordStorage(reservation, UploadStorageState.UNKNOWN);
|
||||
try
|
||||
{
|
||||
storage.put(candidate.bucketName(), PutRequest.of(candidate.storageKey(), bytes).withContentLength(candidate.sizeBytes())
|
||||
.withContentType(candidate.contentType()).withContentDisposition("attachment").withIfNoneMatch(true)
|
||||
.withMetadata(Map.of("qqq-upload-manifest", candidate.manifestHash())));
|
||||
}
|
||||
catch(RuntimeException uncertain)
|
||||
{
|
||||
try
|
||||
{
|
||||
verify(candidate);
|
||||
}
|
||||
catch(QException verification)
|
||||
{
|
||||
verification.addSuppressed(uncertain);
|
||||
throw (verification);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
verify(candidate);
|
||||
}
|
||||
reservation = journal.renew(reservation, LEASE);
|
||||
journal.recordStorage(reservation, UploadStorageState.VERIFIED);
|
||||
return (new Receipt(register(reservation), false));
|
||||
}
|
||||
finally
|
||||
{
|
||||
journal.release(reservation);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard recovery reads an already stored original and never writes bytes.
|
||||
** Coordinated intake must retry through its original coordinator and scope.
|
||||
*******************************************************************************/
|
||||
QRecord recover(Integer journalId) throws QException
|
||||
{
|
||||
requireIndependent();
|
||||
UploadEntry entry = journal.find(journalId).orElseThrow(() -> new QNotFoundException("Not found"));
|
||||
if(!PersonalRecords.currentUserId().equals(entry.candidate().actorId()))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
if(entry.state() == UploadJournalState.COMPLETE)
|
||||
{
|
||||
QRecord row = stored(entry);
|
||||
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
|
||||
return (row);
|
||||
}
|
||||
authorize(entry.candidate());
|
||||
if(entry.candidate().intakeKey() != null)
|
||||
{
|
||||
throw (new QException("Retry this upload through its original intake request to preserve its registration context."));
|
||||
}
|
||||
UploadReservation reservation = journal.reserve(entry.id(), LEASE);
|
||||
try
|
||||
{
|
||||
lifecycle.beforeRecovery(row(entry.candidate()));
|
||||
verify(entry.candidate());
|
||||
reservation = journal.renew(reservation, LEASE);
|
||||
journal.recordStorage(reservation, UploadStorageState.VERIFIED);
|
||||
return (register(reservation));
|
||||
}
|
||||
finally
|
||||
{
|
||||
journal.release(reservation);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Attachment and journal checkpoint commit together. Replays use the same
|
||||
** immutable attachment id and retry the mandatory idempotent lifecycle hook.
|
||||
*******************************************************************************/
|
||||
private QRecord register(UploadReservation reservation) throws QException
|
||||
{
|
||||
authorize(reservation.candidate());
|
||||
UploadEntry entry = journal.register(reservation, (transaction, candidate, journalId) ->
|
||||
{
|
||||
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withTransaction(transaction).withRecords(List.of(row(candidate)));
|
||||
input.withPermissionBypass("Retained owner journal reauthorized the original actor and current parent attach capability.");
|
||||
List<QRecord> records = new InsertAction().execute(input).getRecords();
|
||||
AttachmentRecords.checkErrors(records);
|
||||
if(records.size() != 1)
|
||||
{
|
||||
throw (new QException("Retained registration did not create exactly one owner row."));
|
||||
}
|
||||
return (records.getFirst().getValueInteger("id"));
|
||||
});
|
||||
QRecord stored = stored(entry);
|
||||
lifecycle.afterStored(new QRecord(stored));
|
||||
reservation = journal.renew(reservation, LEASE);
|
||||
journal.complete(reservation);
|
||||
return (stored);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact original bytes must be readable; missing or ambiguous storage never
|
||||
** becomes a cancellation or permission to delete the source record.
|
||||
*******************************************************************************/
|
||||
private void verify(UploadCandidate candidate) throws QException
|
||||
{
|
||||
if(candidate.sizeBytes() > MAX_BYTES)
|
||||
{
|
||||
throw (new QException("Retained recovery exceeds the qualified content bound."));
|
||||
}
|
||||
try(ObjectStream object = storage.open(candidate.bucketName(), candidate.storageKey()))
|
||||
{
|
||||
byte[] bytes = object.getInputStream().readNBytes(Math.toIntExact(candidate.sizeBytes()) + 1);
|
||||
if(bytes.length != candidate.sizeBytes() || !hash(bytes).equals(candidate.sha256()))
|
||||
{
|
||||
throw (new QException("Stored original does not match its immutable journal manifest."));
|
||||
}
|
||||
}
|
||||
catch(IOException | RuntimeException failure)
|
||||
{
|
||||
throw (new QException("Original storage remains unresolved; no absence or deletion is inferred.", failure));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the checkpoint without exposing storage coordinates to clients.
|
||||
*******************************************************************************/
|
||||
private QRecord stored(UploadEntry entry) throws QException
|
||||
{
|
||||
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, entry.attachmentId()).withLimit(1));
|
||||
if(rows.size() != 1 || !entry.candidate().sha256().equals(rows.getFirst().getValueString("sha256"))
|
||||
|| !entry.candidate().sizeBytes().equals(rows.getFirst().getValueLong("sizeBytes"))
|
||||
|| !entry.candidate().actorId().equals(rows.getFirst().getValueString("createdByUserId")))
|
||||
{
|
||||
throw (new QException("Retained journal checkpoint does not match its immutable attachment."));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorization is current and actor-specific; the manifest cannot impersonate.
|
||||
*******************************************************************************/
|
||||
private void authorize(UploadCandidate candidate) throws QException
|
||||
{
|
||||
if(!PersonalRecords.currentUserId().equals(candidate.actorId()))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
ParentRecordAccess.requireFeature(candidate.tableName(), candidate.recordId(), "attach");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retained registration cannot participate in an unrelated caller transaction.
|
||||
*******************************************************************************/
|
||||
private void requireIndependent() throws QException
|
||||
{
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new QException("Retained upload recovery requires its own committed transactions."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Server-owned values only; no client-supplied locator or actor is accepted.
|
||||
*******************************************************************************/
|
||||
private UploadCandidate candidate(QRecord row)
|
||||
{
|
||||
return (new UploadCandidate(row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("createdByUserId"),
|
||||
row.getValueString("fileName"), row.getValueString("contentType"), row.getValueString("description"), row.getValueLong("sizeBytes"),
|
||||
row.getValueString("sha256"), row.getValueString("bucketName"), row.getValueString("storageKey"), row.getValueBoolean("retained"),
|
||||
row.getValueString("intakeKey"), row.getValueString("intakeFingerprint")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reconstruct only owner metadata from the immutable saved manifest.
|
||||
*******************************************************************************/
|
||||
private QRecord row(UploadCandidate candidate)
|
||||
{
|
||||
return (new QRecord().withValue("tableName", candidate.tableName()).withValue("recordId", candidate.recordId())
|
||||
.withValue("createdByUserId", candidate.actorId()).withValue("fileName", candidate.fileName()).withValue("contentType", candidate.contentType())
|
||||
.withValue("description", candidate.description()).withValue("sizeBytes", candidate.sizeBytes()).withValue("sha256", candidate.sha256())
|
||||
.withValue("bucketName", candidate.bucketName()).withValue("storageKey", candidate.storageKey()).withValue("retained", true)
|
||||
.withValue("parentDeleted", false).withValue("intakeKey", candidate.intakeKey()).withValue("intakeFingerprint", candidate.intakeFingerprint()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of actual bounded bytes, never a storage metadata assertion.
|
||||
*******************************************************************************/
|
||||
private String hash(byte[] bytes) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)));
|
||||
}
|
||||
catch(NoSuchAlgorithmException failure)
|
||||
{
|
||||
throw (new QException("SHA-256 is unavailable.", failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
-123
@@ -1,123 +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.collaboration.attachments;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalTable;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A visible native recovery action for retained original registration. It never
|
||||
** accepts replacement bytes, a storage path, an actor, or an intake scope.
|
||||
*******************************************************************************/
|
||||
public final class RecoverRetainedUploadProcess
|
||||
{
|
||||
public static final String NAME = "collaborationRecoverRetainedUpload";
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata utility.
|
||||
*******************************************************************************/
|
||||
private RecoverRetainedUploadProcess()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard QQQ confirmation and result screens, attached to the journal table.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata()
|
||||
{
|
||||
return (new QProcessMetaData().withName(NAME).withTableName(UploadJournalTable.NAME).withLabel("Recover retained upload")
|
||||
.withMinInputRecords(1).withMaxInputRecords(1).withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class))
|
||||
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
|
||||
.withRecordListMetaData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData().withTableName(UploadJournalTable.NAME))))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("confirm").withLabel("Recover the original upload")
|
||||
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
|
||||
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("confirmRecovery", QFieldType.BOOLEAN).withLabel("Verify the stored original and retry registration").withDefaultValue(false).withIsRequired(true)))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("recover").withCode(new QCodeReference(Recover.class)))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("result").withLabel("Original registration saved")
|
||||
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
|
||||
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.VIEW_FORM))
|
||||
.withViewField(new QFieldMetaData("attachmentId", QFieldType.INTEGER).withLabel("Retained attachment")
|
||||
.withPossibleValueSourceName(com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve one visible journal selection before displaying confirmation.
|
||||
*******************************************************************************/
|
||||
public static final class Prepare implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Read-only preview under current actor and parent visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(input.getRecords() == null || input.getRecords().size() != 1)
|
||||
{
|
||||
throw (new QException("Select exactly one retained upload."));
|
||||
}
|
||||
var row = new GetAction().execute(new GetInput(UploadJournalTable.NAME).withPrimaryKey(input.getRecords().getFirst().getValue("id"))).getRecord();
|
||||
if(row == null)
|
||||
{
|
||||
throw (new QException("Retained upload is unavailable."));
|
||||
}
|
||||
if(row.getValueString("intakeKey") != null)
|
||||
{
|
||||
throw (new QException("Retry this upload through its original intake request to preserve its context."));
|
||||
}
|
||||
output.addValue("journalId", row.getValue("id"));
|
||||
output.addValue("confirmRecovery", false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execution reauthorizes independently of the earlier preview.
|
||||
*******************************************************************************/
|
||||
public static final class Recover implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The owner returns only after mandatory lifecycle registration completes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(!Boolean.TRUE.equals(input.getValue("confirmRecovery")))
|
||||
{
|
||||
throw (new QException("Confirm retained upload recovery to continue."));
|
||||
}
|
||||
output.addValue("attachmentId", new AttachmentsService().recoverRetainedUpload(input.getValueInteger("journalId")).id());
|
||||
}
|
||||
}
|
||||
}
|
||||
-227
@@ -1,227 +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.collaboration.attachments;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit provider settlement through the standard table action and Review flow.
|
||||
*******************************************************************************/
|
||||
public class ResolveAttachmentUploadProcess implements CustomPermissionChecker
|
||||
{
|
||||
public static final String NAME = "collaborationResolveAttachmentUpload";
|
||||
public static final String EVIDENCE = "settlementEvidence";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Administrative table action, never a navigation entry or scheduled process.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.ATTACHMENT_CLEANUP);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.ATTACHMENT_CLEANUP);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"Confirm that the provider has completed or cancelled every selected upload. Object absence or elapsed time alone is not proof. Current bytes will be deleted; the settlement receipt will be retained.");
|
||||
return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values)
|
||||
.withName(NAME).withLabel("Resolve settled uploads").withTableName(CollaborationTables.ATTACHMENT_CLEANUP).withIcon(new QIcon("check"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
|
||||
.withCustomPermissionChecker(new QCodeReference(ResolveAttachmentUploadProcess.class)))
|
||||
.withStep(0, new QFrontendStepMetaData().withName("evidence").withLabel("Confirm provider settlement")
|
||||
.withFormField(new QFieldMetaData(EVIDENCE, QFieldType.STRING).withLabel("Provider completion or cancellation evidence")
|
||||
.withMaxLength(2000).withIsRequired(true))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard direct process invocation as well as menu visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
AttachmentCleanupQueue.requireSettlementOperator();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared standard summary language for Review and Result.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String future, String past)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status);
|
||||
line.setSingularFutureMessage(future);
|
||||
line.setPluralFutureMessage(future);
|
||||
line.setSingularPastMessage(past);
|
||||
line.setPluralPastMessage(past);
|
||||
return (line);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review never changes provider state or records a completed settlement.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine selected = line(Status.OK, "uploads will be resolved", "uploads selected");
|
||||
private final ProcessSummaryLine skipped = line(Status.WARNING, "resolved records or committed deletions will be skipped", "records skipped");
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Require evidence before allowing the administrator to review selected rows.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AttachmentCleanupQueue.requireSettlementOperator();
|
||||
AttachmentCleanupQueue.validateEvidence(input.getValueString(EVIDENCE));
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
if(row.getValue("attachmentId") != null || row.getValue("resolvedAt") != null)
|
||||
{
|
||||
skipped.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
else
|
||||
{
|
||||
output.getRecords().add(row);
|
||||
selected.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep streamed totals across pages.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
selected.addSelfToListIfAnyCount(lines);
|
||||
skipped.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply checks current authorization, row ownership and references again.
|
||||
*******************************************************************************/
|
||||
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final AttachmentCleanupQueue queue;
|
||||
private final ProcessSummaryLine done = line(Status.OK, "uploads will be resolved", "uploads resolved");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "uploads cannot be resolved", "uploads unresolved; inspect and retry");
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Construction is also used during context-free metadata validation.
|
||||
*******************************************************************************/
|
||||
public Apply()
|
||||
{
|
||||
this(new AttachmentCleanupQueue());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the same service with a fixture storage provider in process tests.
|
||||
*******************************************************************************/
|
||||
public Apply(AttachmentCleanupQueue queue)
|
||||
{
|
||||
this.queue = queue;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each locator has its own transaction and immutable settlement receipt.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AttachmentCleanupQueue.requireSettlementOperator();
|
||||
AttachmentCleanupQueue.validateEvidence(input.getValueString(EVIDENCE));
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
try
|
||||
{
|
||||
queue.resolveUpload(row.getValue("id"), input.getValueString(EVIDENCE));
|
||||
done.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Report actual application results and retain review exclusions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
done.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
if(getTransformStep() instanceof Review review)
|
||||
{
|
||||
review.skipped.addSelfToListIfAnyCount(lines);
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
-99
@@ -1,99 +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.collaboration.attachments;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled durable attachment cleanup. Runs with its caller's session, which must be a
|
||||
** scheduler system session or collaboration admin; never elevates a user.
|
||||
*******************************************************************************/
|
||||
public class RetryAttachmentCleanupProcess implements BackendStep, CustomPermissionChecker
|
||||
{
|
||||
public static final String NAME = "collaborationRetryAttachmentCleanup";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always register the backend-only process; scheduling is opt-in.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata(CollaborationQBitConfig config)
|
||||
{
|
||||
QProcessMetaData process = new QProcessMetaData().withName(NAME).withLabel("Retry attachment cleanup").withIsHidden(true).withIcon(new QIcon("history"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName("collaboration").withCustomPermissionChecker(new QCodeReference(RetryAttachmentCleanupProcess.class)))
|
||||
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(RetryAttachmentCleanupProcess.class)));
|
||||
if(config.getSchedulerName() != null)
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(60).withInitialDelaySeconds(60));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hidden is a presentation setting, so explicitly guard execution as well.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(CollaborationPermissions.ADMIN)))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the collaboration.admin permission."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retry committed cleanup intents after provider or process recovery.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
checkPermissionsThrowing(input, null);
|
||||
new AttachmentCleanupQueue().retryPending();
|
||||
}
|
||||
}
|
||||
-799
@@ -1,799 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.TimeZone;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalException.Reason;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
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.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.RDBMSBackendModule;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real JDBC implementation for file-backed H2 with WRITE_DELAY=0 and explicitly
|
||||
** qualified MySQL 8.4/InnoDB and PostgreSQL 17 connections, with the
|
||||
** journal and the attachment table on the same backend. This is not a portable
|
||||
** production database claim; any other backend, a memory database, a no-op
|
||||
** transaction or an ambient caller transaction is rejected before any write.
|
||||
**
|
||||
** Every operation opens and owns its own transaction (commit on success, rollback
|
||||
** on any failure; no partial success). Mutations lock the row with a bounded
|
||||
** FOR UPDATE, verify the lease token, fence and expiry, and then also guard the
|
||||
** UPDATE with the same token and fence and require exactly one row. SQL uses only
|
||||
** the fixed owner table name and literal text; all values are bound parameters.
|
||||
** Nothing here ever deletes a row or an object, and it never touches bytes.
|
||||
**
|
||||
** Expiry uses this JVM's clock. Hosts must keep lease durations well above their
|
||||
** clock skew; the fence, not the clock, is what stops a stale holder from
|
||||
** writing once a newer reservation exists.
|
||||
**
|
||||
** Runtime checks cannot qualify a deployment's disks, backups or failover policy.
|
||||
*******************************************************************************/
|
||||
public final class JdbcUploadJournal implements UploadJournal
|
||||
{
|
||||
private static final String TABLE = CollaborationTables.UPLOAD_JOURNAL;
|
||||
private static final int QUERY_TIMEOUT_SECONDS = 10;
|
||||
private static final int MAXIMUM_LIST = 500;
|
||||
private static final Duration MINIMUM_LEASE = Duration.ofSeconds(1);
|
||||
private static final Duration MAXIMUM_LEASE = Duration.ofMinutes(30);
|
||||
|
||||
private static final String COLUMNS = "id, table_name, record_id, created_by_user_id, file_name, content_type, description, size_bytes, sha256,"
|
||||
+ " bucket_name, storage_key, retained, intake_key, intake_fingerprint, locator_hash, manifest_hash, state, storage_state, attachment_id,"
|
||||
+ " lease_token, lease_until, fence";
|
||||
private static final String SELECT_ALL = "SELECT " + COLUMNS + " FROM " + TABLE;
|
||||
private static final String BY_ID = SELECT_ALL + " WHERE id = ?";
|
||||
private static final String BY_ID_LOCKED = BY_ID + " FOR UPDATE";
|
||||
private static final String BY_LOCATOR = SELECT_ALL + " WHERE locator_hash = ?";
|
||||
private static final String BY_INTAKE = SELECT_ALL + " WHERE intake_key = ?";
|
||||
private static final String INCOMPLETE = SELECT_ALL + " WHERE state <> 'COMPLETE' ORDER BY id LIMIT ?";
|
||||
|
||||
private static final String INSERT = "INSERT INTO " + TABLE + " (create_date, modify_date, table_name, record_id, created_by_user_id, file_name,"
|
||||
+ " content_type, description, size_bytes, sha256, bucket_name, storage_key, retained, intake_key, intake_fingerprint, locator_hash,"
|
||||
+ " manifest_hash, state, storage_state, fence) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PREPARED', 'PENDING', 0)";
|
||||
private static final String GRANT = "UPDATE " + TABLE + " SET lease_token = ?, lease_until = ?, fence = ?, modify_date = ? WHERE id = ? AND fence = ?";
|
||||
private static final String SET_STORAGE = "UPDATE " + TABLE + " SET storage_state = ?, modify_date = ? WHERE id = ? AND lease_token = ? AND fence = ?";
|
||||
private static final String SET_REGISTERED = "UPDATE " + TABLE + " SET state = 'REGISTERED', attachment_id = ?, modify_date = ?"
|
||||
+ " WHERE id = ? AND lease_token = ? AND fence = ? AND state = 'PREPARED'";
|
||||
private static final String SET_COMPLETE = "UPDATE " + TABLE + " SET state = 'COMPLETE', lease_token = NULL, lease_until = NULL, modify_date = ?"
|
||||
+ " WHERE id = ? AND lease_token = ? AND fence = ? AND state = 'REGISTERED'";
|
||||
private static final String CLEAR_LEASE = "UPDATE " + TABLE + " SET lease_token = NULL, lease_until = NULL, modify_date = ?"
|
||||
+ " WHERE id = ? AND lease_token = ? AND fence = ?";
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal using the system clock.
|
||||
*******************************************************************************/
|
||||
public JdbcUploadJournal()
|
||||
{
|
||||
this(Clock.systemUTC());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-local deterministic clock seam for lease-expiry tests.
|
||||
*******************************************************************************/
|
||||
JdbcUploadJournal(Clock clock)
|
||||
{
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One database transaction's worth of work.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Work<T>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Run inside the owned, qualified transaction.
|
||||
*******************************************************************************/
|
||||
T run(RDBMSTransaction transaction, Connection connection) throws SQLException, QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A locked or read row with the journal-private secrets.
|
||||
*******************************************************************************/
|
||||
private record Row(UploadEntry entry, String leaseToken, String manifestHash)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static admission of file H2, native mysql, or the native postgres backend
|
||||
** (vendor and backend type both "postgres"). Actual engine, catalog, table
|
||||
** storage and durability checks additionally run in every transaction.
|
||||
*******************************************************************************/
|
||||
public static boolean isQualifiedBackend(QBackendMetaData backend)
|
||||
{
|
||||
if(backend instanceof RDBMSBackendMetaData postgres && PostgresJournalDatabase.is(postgres))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
return (backend instanceof RDBMSBackendMetaData metadata && RDBMSBackendModule.NAME.equals(metadata.getBackendType())
|
||||
&& ("mysql".equals(metadata.getVendor()) || ("h2".equals(metadata.getVendor())
|
||||
&& metadata.getJdbcUrl() != null && metadata.getJdbcUrl().startsWith("jdbc:h2:file:"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadEntry prepare(UploadCandidate candidate) throws QException
|
||||
{
|
||||
Objects.requireNonNull(candidate, "candidate");
|
||||
try
|
||||
{
|
||||
return (inTransaction((transaction, connection) -> insertOrReplay(connection, candidate)));
|
||||
}
|
||||
catch(UploadJournalException e)
|
||||
{
|
||||
if(e.getReason() != Reason.DUPLICATE)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
// Lost a unique-key race: the winner committed, so read its row and compare.
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
UploadEntry existing = existing(connection, candidate);
|
||||
if(existing == null)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The journal reported a duplicate but no matching row exists."));
|
||||
}
|
||||
return (existing);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadReservation reserve(Integer journalId, Duration lease) throws QException
|
||||
{
|
||||
if(lease == null || lease.compareTo(MINIMUM_LEASE) < 0 || lease.compareTo(MAXIMUM_LEASE) > 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("The lease must be between 1 second and 30 minutes."));
|
||||
}
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row row = lock(connection, journalId);
|
||||
Instant now = now();
|
||||
if(row.entry().state() == UploadJournalState.COMPLETE)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "This upload is already complete."));
|
||||
}
|
||||
if(row.entry().isLeased(now))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.LEASE_HELD, "Another holder has a live reservation on this upload."));
|
||||
}
|
||||
String token = UUID.randomUUID().toString();
|
||||
Long fence = row.entry().fence() + 1;
|
||||
Instant until = now.plus(lease);
|
||||
if(update(connection, GRANT, token, Timestamp.from(until), fence, Timestamp.from(now), journalId, row.entry().fence()) != 1)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.LEASE_HELD, "Another holder took this reservation first."));
|
||||
}
|
||||
return (new UploadReservation(row.entry().leased(fence, until), token, fence, until));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadEntry recordStorage(UploadReservation reservation, UploadStorageState storageState) throws QException
|
||||
{
|
||||
Objects.requireNonNull(reservation, "reservation");
|
||||
if(storageState == null || storageState == UploadStorageState.PENDING)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "Storage knowledge can only advance to UNKNOWN or VERIFIED."));
|
||||
}
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row row = requireHolder(connection, reservation);
|
||||
UploadStorageState current = row.entry().storageState();
|
||||
if(current == storageState)
|
||||
{
|
||||
return (row.entry());
|
||||
}
|
||||
if(current == UploadStorageState.VERIFIED)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "Verified storage cannot move back to UNKNOWN."));
|
||||
}
|
||||
if(update(connection, SET_STORAGE, storageState.name(), Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
|
||||
{
|
||||
throw (stale());
|
||||
}
|
||||
return (read(connection, reservation.journalId()));
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew at synchronous stage boundaries without reviving an expired holder.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadReservation renew(UploadReservation reservation, Duration lease) throws QException
|
||||
{
|
||||
if(lease == null || lease.compareTo(MINIMUM_LEASE) < 0 || lease.compareTo(MAXIMUM_LEASE) > 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("The lease must be between 1 second and 30 minutes."));
|
||||
}
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row row = requireHolder(connection, reservation);
|
||||
Instant until = now().plus(lease);
|
||||
if(update(connection, "UPDATE " + TABLE + " SET lease_until=?, modify_date=? WHERE id=? AND lease_token=? AND fence=?",
|
||||
Timestamp.from(until), Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
|
||||
{
|
||||
throw (stale());
|
||||
}
|
||||
return (new UploadReservation(row.entry().leased(reservation.fence(), until), reservation.leaseToken(), reservation.fence(), until));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadEntry register(UploadReservation reservation, AttachmentRegistrar registrar) throws QException
|
||||
{
|
||||
Objects.requireNonNull(reservation, "reservation");
|
||||
Objects.requireNonNull(registrar, "registrar");
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row row = requireHolder(connection, reservation);
|
||||
if(row.entry().state() != UploadJournalState.PREPARED)
|
||||
{
|
||||
return (row.entry());
|
||||
}
|
||||
if(row.entry().storageState() != UploadStorageState.VERIFIED)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.STORAGE_NOT_VERIFIED, "The original object has not been verified, so it cannot be registered."));
|
||||
}
|
||||
Integer attachmentId = registrar.insert(transaction, row.entry().candidate(), row.entry().id());
|
||||
if(attachmentId == null || attachmentId < 1)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The registrar did not return an attachment id."));
|
||||
}
|
||||
if(connection.isClosed() || connection.getAutoCommit())
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The registrar altered the journal's owned transaction."));
|
||||
}
|
||||
if(update(connection, SET_REGISTERED, attachmentId, Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
|
||||
{
|
||||
throw (stale());
|
||||
}
|
||||
return (read(connection, reservation.journalId()));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadEntry complete(UploadReservation reservation) throws QException
|
||||
{
|
||||
Objects.requireNonNull(reservation, "reservation");
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row finished = lock(connection, reservation.journalId());
|
||||
if(finished.entry().state() == UploadJournalState.COMPLETE && finished.entry().fence().equals(reservation.fence()))
|
||||
{
|
||||
return (finished.entry());
|
||||
}
|
||||
Row row = requireHolder(connection, reservation);
|
||||
if(row.entry().state() != UploadJournalState.REGISTERED || row.entry().attachmentId() == null)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "Only a registered upload can be completed."));
|
||||
}
|
||||
if(update(connection, SET_COMPLETE, Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
|
||||
{
|
||||
throw (stale());
|
||||
}
|
||||
return (read(connection, reservation.journalId()));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean release(UploadReservation reservation) throws QException
|
||||
{
|
||||
Objects.requireNonNull(reservation, "reservation");
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row row = lock(connection, reservation.journalId());
|
||||
if(row.entry().state() == UploadJournalState.COMPLETE || !holds(row, reservation))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (update(connection, CLEAR_LEASE, Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) == 1);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<UploadEntry> find(Integer journalId) throws QException
|
||||
{
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
List<Row> rows = select(connection, BY_ID, journalId);
|
||||
return (rows.isEmpty() ? Optional.<UploadEntry>empty() : Optional.of(rows.getFirst().entry()));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<UploadEntry> findByLocation(String bucketName, String storageKey) throws QException
|
||||
{
|
||||
String hash = UploadCandidate.locatorHash(bucketName, storageKey);
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
List<Row> rows = select(connection, BY_LOCATOR, hash);
|
||||
return (rows.isEmpty() ? Optional.<UploadEntry>empty() : Optional.of(rows.getFirst().entry()));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<UploadEntry> listIncomplete(int limit) throws QException
|
||||
{
|
||||
if(limit < 1 || limit > MAXIMUM_LIST)
|
||||
{
|
||||
throw (new IllegalArgumentException("The limit must be between 1 and " + MAXIMUM_LIST + "."));
|
||||
}
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
List<UploadEntry> entries = new ArrayList<>();
|
||||
for(Row row : select(connection, INCOMPLETE, limit))
|
||||
{
|
||||
entries.add(row.entry());
|
||||
}
|
||||
return (List.copyOf(entries));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replay an identical manifest or insert the candidate as PREPARED.
|
||||
*******************************************************************************/
|
||||
private UploadEntry insertOrReplay(Connection connection, UploadCandidate candidate) throws SQLException, QException
|
||||
{
|
||||
UploadEntry existing = existing(connection, candidate);
|
||||
if(existing != null)
|
||||
{
|
||||
return (existing);
|
||||
}
|
||||
Timestamp now = Timestamp.from(now());
|
||||
update(connection, INSERT, now, now, candidate.tableName(), candidate.recordId(), candidate.actorId(), candidate.fileName(),
|
||||
candidate.contentType(), candidate.description(), candidate.sizeBytes(), candidate.sha256(), candidate.bucketName(),
|
||||
candidate.storageKey(), candidate.retained(), candidate.intakeKey(), candidate.intakeFingerprint(), candidate.locatorHash(),
|
||||
candidate.manifestHash());
|
||||
List<Row> rows = select(connection, BY_LOCATOR, candidate.locatorHash());
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The prepared candidate could not be read back."));
|
||||
}
|
||||
return (rows.getFirst().entry());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The row already holding this location, if its manifest is identical. Any other
|
||||
** row sharing the location or intake key is an immutable conflict. Null if none.
|
||||
*******************************************************************************/
|
||||
private UploadEntry existing(Connection connection, UploadCandidate candidate) throws SQLException, QException
|
||||
{
|
||||
List<Row> byLocator = select(connection, BY_LOCATOR, candidate.locatorHash());
|
||||
if(!byLocator.isEmpty())
|
||||
{
|
||||
if(!candidate.manifestHash().equals(byLocator.getFirst().manifestHash()))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "This object location is already journaled with a different manifest."));
|
||||
}
|
||||
return (byLocator.getFirst().entry());
|
||||
}
|
||||
if(candidate.intakeKey() != null && !select(connection, BY_INTAKE, candidate.intakeKey()).isEmpty())
|
||||
{
|
||||
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "This intake key is already journaled for a different object."));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lock the row under the bounded database wait, or NOT_FOUND.
|
||||
*******************************************************************************/
|
||||
private Row lock(Connection connection, Integer journalId) throws SQLException, QException
|
||||
{
|
||||
List<Row> rows = journalId == null ? List.of() : select(connection, BY_ID_LOCKED, journalId);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new UploadJournalException(Reason.NOT_FOUND, "No such upload journal row."));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lock the row and require the reservation to still own a live lease on it.
|
||||
*******************************************************************************/
|
||||
private Row requireHolder(Connection connection, UploadReservation reservation) throws SQLException, QException
|
||||
{
|
||||
Row row = lock(connection, reservation.journalId());
|
||||
if(row.entry().state() == UploadJournalState.COMPLETE || !holds(row, reservation))
|
||||
{
|
||||
throw (stale());
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Token (compared in constant time), fence and unexpired lease all match.
|
||||
*******************************************************************************/
|
||||
private boolean holds(Row row, UploadReservation reservation)
|
||||
{
|
||||
return (row.leaseToken() != null && reservation.leaseToken() != null
|
||||
&& MessageDigest.isEqual(row.leaseToken().getBytes(StandardCharsets.UTF_8), reservation.leaseToken().getBytes(StandardCharsets.UTF_8))
|
||||
&& row.entry().fence().equals(reservation.fence()) && row.entry().isLeased(now()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The standard refusal for a holder that no longer owns the row.
|
||||
*******************************************************************************/
|
||||
private static UploadJournalException stale()
|
||||
{
|
||||
return (new UploadJournalException(Reason.STALE_RESERVATION, "The reservation no longer owns this upload; reserve it again."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read one row that the transaction just changed.
|
||||
*******************************************************************************/
|
||||
private UploadEntry read(Connection connection, Integer journalId) throws SQLException, QException
|
||||
{
|
||||
List<Row> rows = select(connection, BY_ID, journalId);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new UploadJournalException(Reason.NOT_FOUND, "No such upload journal row."));
|
||||
}
|
||||
return (rows.getFirst().entry());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded query with one bound parameter, verifying each row's integrity.
|
||||
*******************************************************************************/
|
||||
private List<Row> select(Connection connection, String sql, Object parameter) throws SQLException, QException
|
||||
{
|
||||
List<Row> rows = new ArrayList<>();
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||
statement.setObject(1, parameter);
|
||||
try(ResultSet result = statement.executeQuery())
|
||||
{
|
||||
while(result.next())
|
||||
{
|
||||
rows.add(row(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rebuild and verify a row: the preserved manifest must still hash to the
|
||||
** stored manifest and locator, or the row was altered outside this journal.
|
||||
*******************************************************************************/
|
||||
private Row row(ResultSet result) throws SQLException, QException
|
||||
{
|
||||
UploadCandidate candidate;
|
||||
try
|
||||
{
|
||||
candidate = new UploadCandidate(result.getString("table_name"), result.getString("record_id"), result.getString("created_by_user_id"),
|
||||
result.getString("file_name"), result.getString("content_type"), result.getString("description"), result.getLong("size_bytes"),
|
||||
result.getString("sha256"), result.getString("bucket_name"), result.getString("storage_key"), result.getBoolean("retained"),
|
||||
result.getString("intake_key"), result.getString("intake_fingerprint"));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "A journal row no longer holds a valid manifest."));
|
||||
}
|
||||
String manifestHash = result.getString("manifest_hash");
|
||||
if(!candidate.manifestHash().equals(manifestHash) || !candidate.locatorHash().equals(result.getString("locator_hash")))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "A journal row's manifest no longer matches its recorded hashes."));
|
||||
}
|
||||
int attachment = result.getInt("attachment_id");
|
||||
Integer attachmentId = result.wasNull() ? null : attachment;
|
||||
Timestamp leaseUntil = "MySQL".equals(result.getStatement().getConnection().getMetaData().getDatabaseProductName())
|
||||
? result.getTimestamp("lease_until", Calendar.getInstance(TimeZone.getTimeZone("UTC"))) : result.getTimestamp("lease_until");
|
||||
UploadEntry entry;
|
||||
try
|
||||
{
|
||||
entry = new UploadEntry(result.getInt("id"), candidate, UploadJournalState.valueOf(result.getString("state")),
|
||||
UploadStorageState.valueOf(result.getString("storage_state")), attachmentId, result.getLong("fence"),
|
||||
leaseUntil == null ? null : leaseUntil.toInstant());
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "A journal row holds an unknown state."));
|
||||
}
|
||||
return (new Row(entry, result.getString("lease_token"), manifestHash));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute one bound update and return its row count.
|
||||
*******************************************************************************/
|
||||
private int update(Connection connection, String sql, Object... parameters) throws SQLException
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||
for(int i = 0; i < parameters.length; i++)
|
||||
{
|
||||
if(parameters[i] instanceof Timestamp timestamp && "MySQL".equals(connection.getMetaData().getDatabaseProductName()))
|
||||
{
|
||||
statement.setTimestamp(i + 1, timestamp, Calendar.getInstance(TimeZone.getTimeZone("UTC")));
|
||||
}
|
||||
else
|
||||
{
|
||||
statement.setObject(i + 1, parameters[i]);
|
||||
}
|
||||
}
|
||||
return (statement.executeUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current time at the millisecond precision the database stores.
|
||||
*******************************************************************************/
|
||||
private Instant now()
|
||||
{
|
||||
return (clock.instant().truncatedTo(ChronoUnit.MILLIS));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Own one real transaction: qualify it, run the work, then commit; roll back
|
||||
** every failure so there is never a partial success.
|
||||
*******************************************************************************/
|
||||
private <T> T inTransaction(Work<T> work) throws QException
|
||||
{
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.AMBIENT_TRANSACTION, "The upload journal owns its transactions and cannot join an ambient one."));
|
||||
}
|
||||
RDBMSBackendMetaData backend = requireQualifiedBackends();
|
||||
try(QBackendTransaction opened = new RDBMSBackendModule().openTransaction(new InsertInput(TABLE)))
|
||||
{
|
||||
if(!(opened instanceof RDBMSTransaction transaction) || !transaction.getClass().equals(RDBMSTransaction.class))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND, "The upload journal requires the database's actual transaction."));
|
||||
}
|
||||
try
|
||||
{
|
||||
requireQualifiedConnection(transaction.getConnection(), backend);
|
||||
T result = work.run(transaction, transaction.getConnection());
|
||||
transaction.commit();
|
||||
return (result);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
rollback(transaction, e);
|
||||
throw (map(e));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
rollback(transaction, failure);
|
||||
throw (failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Roll back, keeping any rollback failure as suppressed on the real failure.
|
||||
*******************************************************************************/
|
||||
private static void rollback(RDBMSTransaction transaction, Throwable failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
transaction.rollback();
|
||||
}
|
||||
catch(QException | RuntimeException rollbackFailure)
|
||||
{
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classify a database failure by SQLSTATE: unique violation, bounded-wait
|
||||
** lock timeout or deadlock, or anything else.
|
||||
*******************************************************************************/
|
||||
private static UploadJournalException map(SQLException e)
|
||||
{
|
||||
Throwable cursor = e;
|
||||
for(int depth = 0; cursor != null && depth < 8; depth++, cursor = cursor.getCause())
|
||||
{
|
||||
if(cursor instanceof SQLException sql)
|
||||
{
|
||||
if("23505".equals(sql.getSQLState()) || ("23000".equals(sql.getSQLState()) && sql.getErrorCode() == 1062))
|
||||
{
|
||||
return (new UploadJournalException(Reason.DUPLICATE, "The journal row already exists.", e));
|
||||
}
|
||||
if("50200".equals(sql.getSQLState()) || "40001".equals(sql.getSQLState()) || "HYT00".equals(sql.getSQLState())
|
||||
|| "55P03".equals(sql.getSQLState()) || "40P01".equals(sql.getSQLState())
|
||||
|| sql.getErrorCode() == 1205 || sql.getErrorCode() == 1213)
|
||||
{
|
||||
return (new UploadJournalException(Reason.BUSY, "The upload journal row stayed busy past the bounded wait.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new UploadJournalException(Reason.DATABASE, "The upload journal database operation failed.", e));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The journal and the attachment table must share one qualified backend, so the
|
||||
** metadata insert and the checkpoint can be one real transaction.
|
||||
*******************************************************************************/
|
||||
private static RDBMSBackendMetaData requireQualifiedBackends() throws QException
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
QBackendMetaData journal = instance == null ? null : instance.getBackendForTable(TABLE);
|
||||
QBackendMetaData attachments = instance == null ? null : instance.getBackendForTable(CollaborationTables.ATTACHMENT);
|
||||
if(!isQualifiedBackend(journal) || attachments == null || !journal.getName().equals(attachments.getName()))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND,
|
||||
"The upload journal requires qualified H2, MySQL 8.4 or PostgreSQL 17 shared by the journal and attachment tables."));
|
||||
}
|
||||
return ((RDBMSBackendMetaData) journal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Qualify the actual connection, not merely its class: open, writable,
|
||||
** transactional, the configured file database, and an actual SETTINGS query
|
||||
** reporting WRITE_DELAY=0 so a commit is on disk before it returns.
|
||||
*******************************************************************************/
|
||||
private static void requireQualifiedConnection(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
|
||||
{
|
||||
if(PostgresJournalDatabase.is(backend))
|
||||
{
|
||||
PostgresJournalDatabase.requireQualifiedConnection(connection, backend);
|
||||
return;
|
||||
}
|
||||
if("mysql".equals(backend.getVendor()))
|
||||
{
|
||||
MySqlJournalDatabase.requireQualifiedConnection(connection, backend);
|
||||
return;
|
||||
}
|
||||
String expected = backend.getJdbcUrl().split(";")[0];
|
||||
if(connection.isClosed() || connection.isReadOnly() || connection.getAutoCommit()
|
||||
|| !expected.equals(String.valueOf(connection.getMetaData().getURL()).split(";")[0]))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND, "The upload journal needs a writable, non-autocommit connection to its configured database."));
|
||||
}
|
||||
boolean writeDelayIsZero = false;
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||
try(ResultSet settings = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.SETTINGS"))
|
||||
{
|
||||
while(settings.next())
|
||||
{
|
||||
if("WRITE_DELAY".equalsIgnoreCase(settings.getString(1)))
|
||||
{
|
||||
writeDelayIsZero = "0".equals(settings.getString(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!writeDelayIsZero)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND, "The upload journal requires H2 WRITE_DELAY=0 as reported by the database's SETTINGS."));
|
||||
}
|
||||
}
|
||||
}
|
||||
-184
@@ -1,184 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Narrow qualification of this owner's fixed MySQL journal and attachment pair.
|
||||
** No schema migration or server-global setting is performed at runtime.
|
||||
*******************************************************************************/
|
||||
final class MySqlJournalDatabase
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private MySqlJournalDatabase()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check the real connection, current durability settings and physical tables.
|
||||
** A host-configured session lock timeout bounds contention without mutation.
|
||||
*******************************************************************************/
|
||||
static void requireQualifiedConnection(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
|
||||
{
|
||||
String expected = baseUrl(ConnectionManager.getJdbcUrl(backend));
|
||||
String actual = baseUrl(connection.getMetaData().getURL());
|
||||
String catalog;
|
||||
try
|
||||
{
|
||||
URI location = URI.create(expected.substring("jdbc:".length()));
|
||||
catalog = location.getPath().substring(1);
|
||||
if(location.getHost() == null || location.getUserInfo() != null || !catalog.matches("[A-Za-z0-9_]+"))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException | IndexOutOfBoundsException e)
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
if(!expected.equals(actual) || !catalog.equals(connection.getCatalog()) || connection.isClosed() || connection.isReadOnly()
|
||||
|| connection.getAutoCommit() || !"MySQL".equals(connection.getMetaData().getDatabaseProductName())
|
||||
|| !connection.getMetaData().getDatabaseProductVersion().startsWith("8.4."))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
try(ResultSet settings = statement.executeQuery("SELECT @@GLOBAL.innodb_flush_log_at_trx_commit, @@GLOBAL.log_bin, @@GLOBAL.sync_binlog, @@GLOBAL.read_only, @@SESSION.time_zone, @@GLOBAL.system_time_zone, @@SESSION.innodb_lock_wait_timeout, @@GLOBAL.innodb_doublewrite, @@SESSION.transaction_read_only"))
|
||||
{
|
||||
if(!settings.next() || !List.of("ON", "DETECT_AND_RECOVER").contains(settings.getString(8)) || settings.getBoolean(9)
|
||||
|| settings.getInt(7) < 1 || settings.getInt(7) > 5 || settings.getInt(1) != 1 || (settings.getBoolean(2) && settings.getInt(3) != 1) || settings.getBoolean(4)
|
||||
|| !(List.of("+00:00", "UTC").contains(settings.getString(5))
|
||||
|| ("SYSTEM".equals(settings.getString(5)) && "UTC".equals(settings.getString(6)))))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(String tableName : List.of(CollaborationTables.UPLOAD_JOURNAL, CollaborationTables.ATTACHMENT))
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
if(table.getBackendDetails() != null && (!(table.getBackendDetails() instanceof RDBMSTableBackendDetails details)
|
||||
|| (details.getTableName() != null && !tableName.equals(details.getTableName()))))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
try(PreparedStatement statement = connection.prepareStatement("SELECT TABLE_NAME, ENGINE, TABLE_COLLATION FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?"))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setString(1, catalog);
|
||||
statement.setString(2, tableName);
|
||||
try(ResultSet tableInfo = statement.executeQuery())
|
||||
{
|
||||
if(!tableInfo.next() || !tableName.equals(tableInfo.getString(1)) || !"InnoDB".equals(tableInfo.getString(2))
|
||||
|| (CollaborationTables.UPLOAD_JOURNAL.equals(tableName) && !"utf8mb4_0900_bin".equals(tableInfo.getString(3))) || tableInfo.next())
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try(PreparedStatement statement = connection.prepareStatement("SELECT COLLATION_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLLATION_NAME IS NOT NULL"))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setString(1, catalog);
|
||||
statement.setString(2, CollaborationTables.UPLOAD_JOURNAL);
|
||||
try(ResultSet columns = statement.executeQuery())
|
||||
{
|
||||
int count = 0;
|
||||
while(columns.next())
|
||||
{
|
||||
count++;
|
||||
if(!"utf8mb4_0900_bin".equals(columns.getString(1)))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
if(count < 1)
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
try(PreparedStatement statement = connection.prepareStatement("SELECT INDEX_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND NON_UNIQUE = 0 ORDER BY INDEX_NAME, SEQ_IN_INDEX"))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setString(1, catalog);
|
||||
statement.setString(2, CollaborationTables.UPLOAD_JOURNAL);
|
||||
try(ResultSet indexes = statement.executeQuery())
|
||||
{
|
||||
java.util.Map<String, List<String>> columns = new java.util.HashMap<>();
|
||||
while(indexes.next())
|
||||
{
|
||||
columns.computeIfAbsent(indexes.getString(1), key -> new java.util.ArrayList<>()).add(indexes.getString(2));
|
||||
}
|
||||
if(!columns.containsValue(List.of("locator_hash")) || !columns.containsValue(List.of("intake_key")))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare the configured target without logging credentials or driver options.
|
||||
*******************************************************************************/
|
||||
private static String baseUrl(String url) throws UploadJournalException
|
||||
{
|
||||
if(url == null || !url.startsWith("jdbc:mysql://"))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
return (url.split("\\?", 2)[0]);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliberately omit connection URLs and settings from externally visible errors.
|
||||
*******************************************************************************/
|
||||
private static UploadJournalException unsupported()
|
||||
{
|
||||
return (new UploadJournalException(UploadJournalException.Reason.UNSUPPORTED_BACKEND,
|
||||
"Retained upload journaling requires its configured MySQL 8.4 catalog, InnoDB owner tables, binary journal identifiers, a UTC session, a 1-5 second lock timeout and synchronous durable commits."));
|
||||
}
|
||||
}
|
||||
-273
@@ -1,273 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Narrow qualification of this owner's fixed PostgreSQL 17 journal and attachment
|
||||
** 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";
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 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(),"
|
||||
+ " 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(?)";
|
||||
|
||||
private static final String COLLATIONS = "SELECT c.relname, a.attname FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace"
|
||||
+ " JOIN pg_collation co ON co.oid=a.attcollation WHERE n.nspname=current_schema() AND c.relname = ANY(?) AND a.attnum>0 AND NOT a.attisdropped AND NOT co.collisdeterministic";
|
||||
|
||||
private static final String UNIQUES = "SELECT i.relname, a.attname FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid"
|
||||
+ " JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON n.oid=t.relnamespace"
|
||||
+ " CROSS JOIN LATERAL unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)"
|
||||
+ " JOIN pg_attribute a ON a.attrelid=t.oid AND a.attnum=k.attnum"
|
||||
+ " WHERE n.nspname=current_schema() AND t.relname = ? AND ix.indisunique AND ix.indisvalid AND ix.indpred IS NULL AND ix.indexprs IS NULL AND k.ord<=ix.indnkeyatts"
|
||||
+ " ORDER BY i.relname, k.ord";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PostgresJournalDatabase()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The native postgres module identity, not merely a vendor string: a "postgres"
|
||||
** vendor label on the generic rdbms backend type is refused.
|
||||
*******************************************************************************/
|
||||
static boolean is(RDBMSBackendMetaData backend)
|
||||
{
|
||||
return (VENDOR.equals(backend.getVendor()) && VENDOR.equals(backend.getBackendType()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check the real connection, current durability settings and physical tables.
|
||||
*******************************************************************************/
|
||||
static void requireQualifiedConnection(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
|
||||
{
|
||||
String expected = baseUrl(ConnectionManager.getJdbcUrl(backend));
|
||||
String actual = baseUrl(connection.getMetaData().getURL());
|
||||
String catalog;
|
||||
try
|
||||
{
|
||||
URI location = URI.create(expected.substring("jdbc:".length()));
|
||||
catalog = location.getPath() == null || !location.getPath().startsWith("/") ? "" : location.getPath().substring(1);
|
||||
if(location.getHost() == null || location.getUserInfo() != null || !catalog.matches("[A-Za-z0-9_]+"))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException | IndexOutOfBoundsException e)
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
if(!is(backend) || !expected.equals(actual) || !catalog.equals(connection.getCatalog()) || connection.isClosed() || connection.isReadOnly()
|
||||
|| connection.getAutoCommit() || !"PostgreSQL".equals(connection.getMetaData().getDatabaseProductName())
|
||||
|| connection.getMetaData().getDatabaseMajorVersion() != 17)
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
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))
|
||||
|| !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)))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> names = List.of(CollaborationTables.UPLOAD_JOURNAL, CollaborationTables.ATTACHMENT);
|
||||
for(String tableName : names)
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
if(table.getBackendDetails() != null && (!(table.getBackendDetails() instanceof RDBMSTableBackendDetails details)
|
||||
|| (details.getTableName() != null && !tableName.equals(details.getTableName()))))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
requireTables(connection, names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
private static void requireTables(Connection connection, List<String> names) throws SQLException, QException
|
||||
{
|
||||
java.sql.Array array = connection.createArrayOf("text", names.toArray());
|
||||
try
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement(TABLES))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setArray(1, array);
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
Set<String> observed = new java.util.HashSet<>();
|
||||
while(rows.next())
|
||||
{
|
||||
if(!names.contains(rows.getString(1)) || !observed.add(rows.getString(1)) || !"r".equals(rows.getString(2)) || !"p".equals(rows.getString(3)))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
if(observed.size() != names.size())
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
try(PreparedStatement statement = connection.prepareStatement(COLLATIONS))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setArray(1, array);
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
if(rows.next())
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
array.free();
|
||||
}
|
||||
try(PreparedStatement statement = connection.prepareStatement(UNIQUES))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setString(1, CollaborationTables.UPLOAD_JOURNAL);
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
Map<String, List<String>> columns = new HashMap<>();
|
||||
while(rows.next())
|
||||
{
|
||||
columns.computeIfAbsent(rows.getString(1), key -> new ArrayList<>()).add(rows.getString(2));
|
||||
}
|
||||
if(!columns.containsValue(List.of("locator_hash")) || !columns.containsValue(List.of("intake_key")))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare the configured target without logging credentials or driver options.
|
||||
*******************************************************************************/
|
||||
private static String baseUrl(String url) throws UploadJournalException
|
||||
{
|
||||
if(url == null || !url.startsWith("jdbc:postgresql://"))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
return (url.split("\\?", 2)[0]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliberately omit connection URLs and settings from externally visible errors.
|
||||
*******************************************************************************/
|
||||
private static UploadJournalException unsupported()
|
||||
{
|
||||
return (new UploadJournalException(UploadJournalException.Reason.UNSUPPORTED_BACKEND,
|
||||
"Retained upload journaling requires its configured PostgreSQL 17 database, permanent owner tables, deterministic comparison, READ COMMITTED isolation, a UTC session, a bounded lock timeout and synchronous durable commits."));
|
||||
}
|
||||
}
|
||||
-198
@@ -1,198 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable, bounded manifest of a retained upload, assembled by the server
|
||||
** (AttachmentsService) from the authorized source, the actual session actor,
|
||||
** the measured size/hash and the chosen storage coordinates. Nothing here is a
|
||||
** client-supplied path or journal field. All members are immutable values.
|
||||
**
|
||||
** Only retained uploads are journaled; a non-retained candidate is rejected.
|
||||
** The bucket and key are needed for recovery but are deliberately absent from
|
||||
** toString and from the QQQ table metadata; the journal exposes only their hash.
|
||||
*******************************************************************************/
|
||||
public record UploadCandidate(String tableName, String recordId, String actorId, String fileName, String contentType, String description,
|
||||
Long sizeBytes, String sha256, String bucketName, String storageKey, Boolean retained,
|
||||
String intakeKey, String intakeFingerprint)
|
||||
{
|
||||
public static final long MAX_SIZE_BYTES = 1L << 40;
|
||||
private static final String HEX_64 = "[0-9a-f]{64}";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate every member. Failures are programming errors in the trusted
|
||||
** caller, so they are unchecked and never echo the offending value.
|
||||
*******************************************************************************/
|
||||
public UploadCandidate
|
||||
{
|
||||
tableName = text("tableName", tableName, 128, true, false);
|
||||
recordId = text("recordId", recordId, 64, true, false);
|
||||
actorId = text("actorId", actorId, 255, true, false);
|
||||
fileName = text("fileName", fileName, 255, true, false);
|
||||
contentType = text("contentType", contentType, 255, true, false);
|
||||
description = text("description", description, 4096, false, true);
|
||||
bucketName = text("bucketName", bucketName, 255, true, false);
|
||||
storageKey = text("storageKey", storageKey, 1024, true, false);
|
||||
if(sizeBytes == null || sizeBytes < 0 || sizeBytes > MAX_SIZE_BYTES)
|
||||
{
|
||||
throw (new IllegalArgumentException("sizeBytes is out of range."));
|
||||
}
|
||||
if(sha256 == null || !sha256.matches(HEX_64))
|
||||
{
|
||||
throw (new IllegalArgumentException("sha256 must be a lowercase SHA-256 hex digest."));
|
||||
}
|
||||
if(!Boolean.TRUE.equals(retained))
|
||||
{
|
||||
throw (new IllegalArgumentException("Only retained uploads are journaled."));
|
||||
}
|
||||
if((intakeKey == null) != (intakeFingerprint == null)
|
||||
|| (intakeKey != null && (!intakeKey.matches(HEX_64) || !intakeFingerprint.matches(HEX_64))))
|
||||
{
|
||||
throw (new IllegalArgumentException("intakeKey and intakeFingerprint must be supplied together as SHA-256 identifiers."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of the length-prefixed bucket and key; the journal's unique identity
|
||||
** for one object location.
|
||||
*******************************************************************************/
|
||||
public String locatorHash()
|
||||
{
|
||||
return (locatorHash(bucketName, storageKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of the length-prefixed bucket and key.
|
||||
*******************************************************************************/
|
||||
public static String locatorHash(String bucketName, String storageKey)
|
||||
{
|
||||
return (tupleHash("upload-journal-locator-v1", bucketName, storageKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 over the complete manifest. A row that shares a locator or intake
|
||||
** key but not this hash is an immutable conflict.
|
||||
*******************************************************************************/
|
||||
public String manifestHash()
|
||||
{
|
||||
return (tupleHash("upload-journal-manifest-v1", tableName, recordId, actorId, fileName, contentType, description,
|
||||
String.valueOf(sizeBytes), sha256, bucketName, storageKey, String.valueOf(retained), intakeKey, intakeFingerprint));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never prints the bucket, key, description or actor.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("UploadCandidate[source=" + tableName + "/" + recordId + ", sizeBytes=" + sizeBytes + ", locatorHash=" + locatorHash() + "]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded text without control characters (multi-line allows CR, LF, tab).
|
||||
*******************************************************************************/
|
||||
private static String text(String name, String value, int maximumLength, boolean required, boolean multiLine)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
if(required)
|
||||
{
|
||||
throw (new IllegalArgumentException(name + " is required."));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
if((required && value.isBlank()) || value.length() > maximumLength)
|
||||
{
|
||||
throw (new IllegalArgumentException(name + " is blank or exceeds " + maximumLength + " characters."));
|
||||
}
|
||||
for(int i = 0; i < value.length(); i++)
|
||||
{
|
||||
char c = value.charAt(i);
|
||||
if(Character.isHighSurrogate(c))
|
||||
{
|
||||
if(i + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(++i)))
|
||||
{
|
||||
throw (new IllegalArgumentException(name + " contains malformed Unicode."));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(Character.isLowSurrogate(c))
|
||||
{
|
||||
throw (new IllegalArgumentException(name + " contains malformed Unicode."));
|
||||
}
|
||||
if(Character.isISOControl(c) && !(multiLine && (c == '\n' || c == '\r' || c == '\t')))
|
||||
{
|
||||
throw (new IllegalArgumentException(name + " contains a control character."));
|
||||
}
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Length-prefix every member, including null, so delimiters cannot collide.
|
||||
*******************************************************************************/
|
||||
private static String tupleHash(String... values)
|
||||
{
|
||||
try
|
||||
{
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try(DataOutputStream output = new DataOutputStream(buffer))
|
||||
{
|
||||
for(String value : values)
|
||||
{
|
||||
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
|
||||
output.writeInt(value == null ? -1 : bytes.length);
|
||||
output.write(bytes);
|
||||
}
|
||||
}
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(buffer.toByteArray())));
|
||||
}
|
||||
catch(IOException | NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not hash the upload manifest.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
-158
@@ -1,158 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable snapshot of one journal row at read time. It never carries the
|
||||
** lease token, so it cannot be used to mutate the journal.
|
||||
*******************************************************************************/
|
||||
public final class UploadEntry
|
||||
{
|
||||
private final Integer id;
|
||||
private final UploadCandidate candidate;
|
||||
private final UploadJournalState state;
|
||||
private final UploadStorageState storageState;
|
||||
private final Integer attachmentId;
|
||||
private final Long fence;
|
||||
private final Instant leaseUntil;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-local; only the journal creates snapshots.
|
||||
*******************************************************************************/
|
||||
UploadEntry(Integer id, UploadCandidate candidate, UploadJournalState state, UploadStorageState storageState, Integer attachmentId, Long fence, Instant leaseUntil)
|
||||
{
|
||||
this.id = id;
|
||||
this.candidate = candidate;
|
||||
this.state = state;
|
||||
this.storageState = storageState;
|
||||
this.attachmentId = attachmentId;
|
||||
this.fence = fence;
|
||||
this.leaseUntil = leaseUntil;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal row id.
|
||||
*******************************************************************************/
|
||||
public Integer id()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The preserved manifest, including the original actor and source.
|
||||
*******************************************************************************/
|
||||
public UploadCandidate candidate()
|
||||
{
|
||||
return (candidate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registration progress.
|
||||
*******************************************************************************/
|
||||
public UploadJournalState state()
|
||||
{
|
||||
return (state);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What is known about the original object.
|
||||
*******************************************************************************/
|
||||
public UploadStorageState storageState()
|
||||
{
|
||||
return (storageState);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The registered attachment id, or null before the registering commit.
|
||||
*******************************************************************************/
|
||||
public Integer attachmentId()
|
||||
{
|
||||
return (attachmentId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Number of reservations ever granted; the fencing counter.
|
||||
*******************************************************************************/
|
||||
public Long fence()
|
||||
{
|
||||
return (fence);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** When the current lease lapses, or null when no lease is held.
|
||||
*******************************************************************************/
|
||||
public Instant leaseUntil()
|
||||
{
|
||||
return (leaseUntil);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True while a lease is held as of the given instant.
|
||||
*******************************************************************************/
|
||||
public boolean isLeased(Instant now)
|
||||
{
|
||||
return (leaseUntil != null && leaseUntil.isAfter(now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy reflecting a newly granted lease.
|
||||
*******************************************************************************/
|
||||
UploadEntry leased(Long newFence, Instant newLeaseUntil)
|
||||
{
|
||||
return (new UploadEntry(id, candidate, state, storageState, attachmentId, newFence, newLeaseUntil));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never prints the manifest's bucket or key.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("UploadEntry[id=" + id + ", state=" + state + ", storage=" + storageState + ", attachmentId=" + attachmentId + ", fence=" + fence + ", " + candidate + "]");
|
||||
}
|
||||
}
|
||||
-141
@@ -1,141 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable journal of retained upload candidates. The intended order, owned by
|
||||
** AttachmentsService (this interface never reads, writes or deletes bytes):
|
||||
**
|
||||
** 1. prepare(candidate) independently committed, before the first PUT
|
||||
** 2. reserve(id, lease) exclusive fenced lease (original or recovery)
|
||||
** 3. recordStorage(UNKNOWN) before the PUT; VERIFIED once bytes are proven
|
||||
** 4. register(reservation, cb) attachment insert + checkpoint, one transaction
|
||||
** 5. (caller) lifecycle.afterStored, after the commit, idempotent and retried
|
||||
** 6. complete(reservation) only after step 5 returned
|
||||
**
|
||||
** A crash, timeout or failure at any step leaves the row visible; recovery
|
||||
** reserves it again once the lease lapses, after the caller reauthorizes the
|
||||
** original actor and the current source. Nothing here deletes an original.
|
||||
**
|
||||
** AttachmentsService owns authorization and lifecycle policy around this port.
|
||||
*******************************************************************************/
|
||||
public interface UploadJournal
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Inserts the registering callback's attachment row. Runs inside the journal's
|
||||
** owned transaction, which the callback must use for every write and must not
|
||||
** commit, roll back or close. Returns the new attachment id. Throwing rolls
|
||||
** back the insert and the checkpoint together.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface AttachmentRegistrar
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Insert the attachment metadata row for the journaled candidate.
|
||||
*******************************************************************************/
|
||||
Integer insert(QBackendTransaction transaction, UploadCandidate candidate, Integer journalId) throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durably record the candidate in its own committed transaction. Idempotent
|
||||
** for an identical manifest; an immutable conflict for any different one.
|
||||
*******************************************************************************/
|
||||
UploadEntry prepare(UploadCandidate candidate) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Take the exclusive lease, failing with LEASE_HELD while another holder's
|
||||
** lease is live. Each grant increments the fence. Lease bounded 1s to 30m.
|
||||
*******************************************************************************/
|
||||
UploadReservation reserve(Integer journalId, Duration lease) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Extend only a still-live exact reservation. Expired holders cannot renew.
|
||||
*******************************************************************************/
|
||||
UploadReservation renew(UploadReservation reservation, Duration lease) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Report original-object knowledge. Forward only: PENDING to UNKNOWN to
|
||||
** VERIFIED. Requires the live reservation.
|
||||
*******************************************************************************/
|
||||
UploadEntry recordStorage(UploadReservation reservation, UploadStorageState storageState) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Requires VERIFIED storage and the live reservation. Inserts the attachment
|
||||
** through the callback and checkpoints its id and REGISTERED in one same-database
|
||||
** transaction. If already REGISTERED the callback is not called again and the
|
||||
** existing entry is returned, so the attachment is never registered twice.
|
||||
*******************************************************************************/
|
||||
UploadEntry register(UploadReservation reservation, AttachmentRegistrar registrar) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark COMPLETE after the post-commit lifecycle registration returned. Requires
|
||||
** REGISTERED and the live reservation; repeating it for the same fence is a no-op.
|
||||
*******************************************************************************/
|
||||
UploadEntry complete(UploadReservation reservation) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Give up the lease early so recovery need not wait. Changes no other data.
|
||||
** Returns false, harmlessly, if the reservation was no longer the holder.
|
||||
*******************************************************************************/
|
||||
boolean release(UploadReservation reservation) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current snapshot of one row.
|
||||
*******************************************************************************/
|
||||
Optional<UploadEntry> find(Integer journalId) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Snapshot found by the object location, hashed internally.
|
||||
*******************************************************************************/
|
||||
Optional<UploadEntry> findByLocation(String bucketName, String storageKey) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Oldest-first rows that are not COMPLETE, for recovery sweeps. Limit 1 to 500.
|
||||
*******************************************************************************/
|
||||
List<UploadEntry> listIncomplete(int limit) throws QException;
|
||||
}
|
||||
-118
@@ -1,118 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal failure with a machine-readable reason. Messages never contain the
|
||||
** bucket, storage key or lease token.
|
||||
*******************************************************************************/
|
||||
public class UploadJournalException extends QException
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Why the journal refused or failed an operation.
|
||||
*******************************************************************************/
|
||||
public enum Reason
|
||||
{
|
||||
/***************************************************************************
|
||||
** Another transaction held the row past the bounded wait.
|
||||
***************************************************************************/
|
||||
BUSY,
|
||||
/***************************************************************************
|
||||
** A live lease belongs to another holder.
|
||||
***************************************************************************/
|
||||
LEASE_HELD,
|
||||
/***************************************************************************
|
||||
** The reservation's token, fence or lease no longer owns the row.
|
||||
***************************************************************************/
|
||||
STALE_RESERVATION,
|
||||
/***************************************************************************
|
||||
** Same locator or intake key, different immutable manifest (or tampered row).
|
||||
***************************************************************************/
|
||||
IMMUTABLE_CONFLICT,
|
||||
/***************************************************************************
|
||||
** The operation does not apply to the row's current state.
|
||||
***************************************************************************/
|
||||
ILLEGAL_STATE,
|
||||
/***************************************************************************
|
||||
** Registration requires VERIFIED original storage.
|
||||
***************************************************************************/
|
||||
STORAGE_NOT_VERIFIED,
|
||||
/***************************************************************************
|
||||
** No such journal row.
|
||||
***************************************************************************/
|
||||
NOT_FOUND,
|
||||
/***************************************************************************
|
||||
** Backend is not the qualified file-backed H2 configuration.
|
||||
***************************************************************************/
|
||||
UNSUPPORTED_BACKEND,
|
||||
/***************************************************************************
|
||||
** The journal owns its transactions and refuses an ambient one.
|
||||
***************************************************************************/
|
||||
AMBIENT_TRANSACTION,
|
||||
/***************************************************************************
|
||||
** Unique constraint hit; handled internally, not normally surfaced.
|
||||
***************************************************************************/
|
||||
DUPLICATE,
|
||||
/***************************************************************************
|
||||
** Other database failure.
|
||||
***************************************************************************/
|
||||
DATABASE
|
||||
}
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public UploadJournalException(Reason reason, String message)
|
||||
{
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor with cause.
|
||||
*******************************************************************************/
|
||||
public UploadJournalException(Reason reason, String message, Throwable cause)
|
||||
{
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The refusal reason.
|
||||
*******************************************************************************/
|
||||
public Reason getReason()
|
||||
{
|
||||
return (reason);
|
||||
}
|
||||
}
|
||||
-159
@@ -1,159 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
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.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Private manifest fields cannot become filter or sorting oracles. This
|
||||
** table-local restriction composes with the host's existing field access policy.
|
||||
*******************************************************************************/
|
||||
public final class UploadJournalReadPolicy
|
||||
{
|
||||
private static final Set<String> PRIVATE = Set.of("bucketName", "storageKey", "manifestHash", "leaseToken");
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility namespace.
|
||||
*******************************************************************************/
|
||||
private UploadJournalReadPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Native read criteria are checked before SQL, including unique-key Get.
|
||||
** Joins are not an journal browsing contract and are rejected conservatively.
|
||||
*******************************************************************************/
|
||||
public static void check(AbstractActionInput input, QTableMetaData table) throws QPermissionDeniedException
|
||||
{
|
||||
if(!UploadJournalTable.NAME.equals(table.getName()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(input instanceof QueryInput query)
|
||||
{
|
||||
if(query.getQueryJoins() != null && !query.getQueryJoins().isEmpty())
|
||||
{
|
||||
deny();
|
||||
}
|
||||
filter(query.getFilter(), table, 0, new int[]{0});
|
||||
if(query.getFieldNamesToInclude() != null)
|
||||
{
|
||||
for(String name : query.getFieldNamesToInclude())
|
||||
{
|
||||
field(name, table);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(input instanceof GetInput get)
|
||||
{
|
||||
if(get.getQueryJoins() != null && !get.getQueryJoins().isEmpty())
|
||||
{
|
||||
deny();
|
||||
}
|
||||
if(get.getUniqueKey() != null)
|
||||
{
|
||||
for(String name : get.getUniqueKey().keySet())
|
||||
{
|
||||
field(name, table);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound traversal and accept only literal criteria on public direct fields.
|
||||
*******************************************************************************/
|
||||
private static void filter(QQueryFilter filter, QTableMetaData table, int depth, int[] visited) throws QPermissionDeniedException
|
||||
{
|
||||
if(filter == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(depth > 64 || ++visited[0] > 4096)
|
||||
{
|
||||
deny();
|
||||
}
|
||||
for(var criterion : filter.getCriteria() == null ? List.<com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria>of() : filter.getCriteria())
|
||||
{
|
||||
field(criterion.getFieldName(), table);
|
||||
if(criterion.getOtherFieldName() != null)
|
||||
{
|
||||
field(criterion.getOtherFieldName(), table);
|
||||
}
|
||||
if(criterion.getFieldFunction() != null)
|
||||
{
|
||||
deny();
|
||||
}
|
||||
for(Object value : criterion.getValues() == null ? List.of() : criterion.getValues())
|
||||
{
|
||||
if(value instanceof com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.CurrentUser
|
||||
&& "createdByUserId".equals(criterion.getFieldName())
|
||||
&& criterion.getOperator() == com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS
|
||||
&& criterion.getValues().size() == 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(value != null && !(value instanceof String) && !(value instanceof Number) && !(value instanceof Boolean)
|
||||
&& !(value instanceof java.time.temporal.TemporalAccessor) && !(value instanceof java.util.Date))
|
||||
{
|
||||
deny();
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var order : filter.getOrderBys() == null ? List.<com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy>of() : filter.getOrderBys())
|
||||
{
|
||||
field(order.getFieldName(), table);
|
||||
}
|
||||
for(QQueryFilter child : filter.getSubFilters() == null ? List.<QQueryFilter>of() : filter.getSubFilters())
|
||||
{
|
||||
filter(child, table, depth + 1, visited);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A public direct field cannot refer to a private projection through an alias.
|
||||
*******************************************************************************/
|
||||
private static void field(String name, QTableMetaData table) throws QPermissionDeniedException
|
||||
{
|
||||
if(name == null || PRIVATE.contains(name) || table.getFields().get(name) == null || Boolean.TRUE.equals(table.getFields().get(name).getIsHidden()))
|
||||
{
|
||||
deny();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Uniform denial never echoes security values or a caller's probing predicate.
|
||||
*******************************************************************************/
|
||||
private static void deny() throws QPermissionDeniedException
|
||||
{
|
||||
throw (new QPermissionDeniedException("Upload journal queries support public direct fields only."));
|
||||
}
|
||||
}
|
||||
-118
@@ -1,118 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissionChecker;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
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.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner journal visibility follows its original actor and its exact attachment's
|
||||
** current parent after a transfer. Immutable manifest source values never change.
|
||||
*******************************************************************************/
|
||||
public class UploadJournalSecurity implements CustomPermissionChecker, TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No direct writes or counts bypass per-row dynamic parent authorization.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table)
|
||||
{
|
||||
UploadJournalReadPolicy.check(input, table);
|
||||
}
|
||||
if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput || input instanceof CountInput || input instanceof AggregateInput)
|
||||
{
|
||||
throw (new QPermissionDeniedException("The upload journal is maintained by the attachment service; unscoped totals are unavailable."));
|
||||
}
|
||||
new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read authoritative owner identity even if the client selected a projection.
|
||||
** Internal manifest columns are not QQQ fields and cannot be returned here.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
|
||||
{
|
||||
List<QRecord> visible = new ArrayList<>();
|
||||
for(QRecord row : records)
|
||||
{
|
||||
if(row.getValueInteger("id") == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
List<QRecord> persisted = CollaborationRecords.cleanupQuery(UploadJournalTable.NAME,
|
||||
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, row.getValueInteger("id")).withLimit(1));
|
||||
if(persisted.size() != 1 || !PersonalRecords.currentUserId().equals(persisted.getFirst().getValueString("createdByUserId")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord source = persisted.getFirst();
|
||||
if(source.getValueInteger("attachmentId") != null)
|
||||
{
|
||||
List<QRecord> attachment = CollaborationRecords.cleanupQuery(CollaborationTables.ATTACHMENT,
|
||||
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, source.getValueInteger("attachmentId")).withLimit(1));
|
||||
if(attachment.size() != 1 || !source.getValueString("sha256").equals(attachment.getFirst().getValueString("sha256"))
|
||||
|| !source.getValueLong("sizeBytes").equals(attachment.getFirst().getValueLong("sizeBytes"))
|
||||
|| !source.getValueString("createdByUserId").equals(attachment.getFirst().getValueString("createdByUserId")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
source = attachment.getFirst();
|
||||
}
|
||||
ParentRecordAccess.requireVisibleParent(source.getValueString("tableName"), source.getValueString("recordId"));
|
||||
visible.add(row);
|
||||
}
|
||||
catch(QNotFoundException denied)
|
||||
{
|
||||
// Invisible and absent owners have identical native query behavior.
|
||||
}
|
||||
}
|
||||
return (visible);
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registration progress of a journaled retained upload. Only moves forward.
|
||||
** NOT YET WIRED: no producer, table registration or service uses this yet.
|
||||
*******************************************************************************/
|
||||
public enum UploadJournalState
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Candidate is durable; no attachment row has been committed for it.
|
||||
*******************************************************************************/
|
||||
PREPARED,
|
||||
|
||||
/*******************************************************************************
|
||||
** The attachment row and its checkpoint committed together; the post-commit
|
||||
** lifecycle registration has not yet been confirmed.
|
||||
*******************************************************************************/
|
||||
REGISTERED,
|
||||
|
||||
/*******************************************************************************
|
||||
** Lifecycle registration returned after the registering commit.
|
||||
*******************************************************************************/
|
||||
COMPLETE
|
||||
}
|
||||
-222
@@ -1,222 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The one fixed journal table: native QQQ metadata for visible, read-only
|
||||
** provenance and status, and explicit H2/MySQL DDL. The bucket, storage key,
|
||||
** manifest hash and lease token are physical columns only; they are NOT QQQ
|
||||
** fields, so no UI or API surface can return a raw object locator or lease.
|
||||
** Only the hash of the locator is exposed.
|
||||
**
|
||||
** The producer registers this table; hosts apply the appropriate owner DDL
|
||||
** through migration tooling before enabling retained upload intake.
|
||||
*******************************************************************************/
|
||||
public final class UploadJournalTable
|
||||
{
|
||||
public static final String NAME = CollaborationTables.UPLOAD_JOURNAL;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private UploadJournalTable()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table metadata with no editable field; direct writes are denied by
|
||||
** UploadJournalSecurity. Field names line up with the parent-reference
|
||||
** metadata used by attachments (tableName, recordId, createdByUserId).
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData metaData(CollaborationQBitConfig config)
|
||||
{
|
||||
return (new QTableMetaData().withName(NAME).withLabel("Retained Upload Journal").withIcon(new QIcon("history_edu"))
|
||||
.withBackendName(config.getBackendName()).withPrimaryKeyField("id")
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules().withCustomPermissionChecker(new QCodeReference(UploadJournalSecurity.class)))
|
||||
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.POST_QUERY_RECORD, new QCodeReference(UploadJournalSecurity.class))
|
||||
.withoutCapabilities(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_COUNT, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.QUERY_STATS)
|
||||
.withRecordLabelFormatAndFields("%s", "fileName")
|
||||
.withUniqueKey(new UniqueKey("locatorHash"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("ID").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withLabel("Created").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withLabel("Modified").withIsEditable(false))
|
||||
.withField(string("tableName", "Table", 128))
|
||||
.withField(string("recordId", "Record ID", 64))
|
||||
.withField(string("createdByUserId", "Original Actor", 255).withPossibleValueSourceName(CollaborationTables.USER))
|
||||
.withField(string("fileName", "File Name", 255))
|
||||
.withField(string("contentType", "Content Type", 255))
|
||||
.withField(new QFieldMetaData("description", QFieldType.TEXT).withLabel("Description").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("sizeBytes", QFieldType.LONG).withLabel("Size (bytes)").withIsEditable(false))
|
||||
.withField(string("sha256", "SHA-256", 64))
|
||||
.withField(new QFieldMetaData("retained", QFieldType.BOOLEAN).withLabel("Retained").withIsEditable(false))
|
||||
.withField(string("intakeKey", "Intake Key", 64))
|
||||
.withField(string("intakeFingerprint", "Intake Fingerprint", 64))
|
||||
.withField(string("locatorHash", "Object Location Hash", 64))
|
||||
.withField(string("state", "Registration State", 16))
|
||||
.withField(string("storageState", "Original Object", 16))
|
||||
.withField(new QFieldMetaData("attachmentId", QFieldType.INTEGER).withLabel("Attachment").withIsEditable(false)
|
||||
.withPossibleValueSourceName(CollaborationTables.ATTACHMENT))
|
||||
.withField(new QFieldMetaData("leaseUntil", QFieldType.DATE_TIME).withLabel("Lease Until").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("fence", QFieldType.LONG).withLabel("Reservation Count").withIsEditable(false))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "fileName", "state", "storageState", "attachmentId")).withLabel("Status"))
|
||||
.withSection(new QFieldSection("source", new QIcon("link"), Tier.T2, List.of("tableName", "recordId", "createdByUserId", "contentType", "sizeBytes", "sha256", "description")).withLabel("Source"))
|
||||
.withSection(new QFieldSection("recovery", new QIcon("storage"), Tier.T3, List.of("locatorHash", "intakeKey", "intakeFingerprint", "leaseUntil", "fence", "retained")).withLabel("Recovery")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** H2 DDL for the qualified adapter only. Unquoted lowercase identifiers match
|
||||
** QQQ's inferred snake_case backend names. The constraints are database
|
||||
** guards: retained rows only, forward-only states, intake pairing, and no
|
||||
** REGISTERED/COMPLETE row without its attachment id. PostgreSQL has its own
|
||||
** script, postgresScript().
|
||||
*******************************************************************************/
|
||||
public static String h2Script()
|
||||
{
|
||||
return ("CREATE TABLE IF NOT EXISTS collaboration_upload_journal (\n"
|
||||
+ " id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n"
|
||||
+ " create_date TIMESTAMP NOT NULL,\n"
|
||||
+ " modify_date TIMESTAMP NOT NULL,\n"
|
||||
+ " table_name VARCHAR(128) NOT NULL,\n"
|
||||
+ " record_id VARCHAR(64) NOT NULL,\n"
|
||||
+ " created_by_user_id VARCHAR(255) NOT NULL,\n"
|
||||
+ " file_name VARCHAR(255) NOT NULL,\n"
|
||||
+ " content_type VARCHAR(255) NOT NULL,\n"
|
||||
+ " description VARCHAR(4096),\n"
|
||||
+ " size_bytes BIGINT NOT NULL,\n"
|
||||
+ " sha256 VARCHAR(64) NOT NULL,\n"
|
||||
+ " bucket_name VARCHAR(255) NOT NULL,\n"
|
||||
+ " storage_key VARCHAR(1024) NOT NULL,\n"
|
||||
+ " retained BOOLEAN NOT NULL,\n"
|
||||
+ " intake_key VARCHAR(64),\n"
|
||||
+ " intake_fingerprint VARCHAR(64),\n"
|
||||
+ " locator_hash VARCHAR(64) NOT NULL,\n"
|
||||
+ " manifest_hash VARCHAR(64) NOT NULL,\n"
|
||||
+ " state VARCHAR(16) NOT NULL,\n"
|
||||
+ " storage_state VARCHAR(16) NOT NULL,\n"
|
||||
+ " attachment_id INTEGER,\n"
|
||||
+ " lease_token VARCHAR(64),\n"
|
||||
+ " lease_until TIMESTAMP,\n"
|
||||
+ " fence BIGINT NOT NULL,\n"
|
||||
+ " CONSTRAINT ck_upload_journal_retained CHECK (retained = TRUE),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_state CHECK (state IN ('PREPARED', 'REGISTERED', 'COMPLETE')),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_storage CHECK (storage_state IN ('PENDING', 'UNKNOWN', 'VERIFIED')),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_intake CHECK ((intake_key IS NULL) = (intake_fingerprint IS NULL)),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_registered CHECK (state = 'PREPARED' OR attachment_id IS NOT NULL),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_verified CHECK (state = 'PREPARED' OR storage_state = 'VERIFIED')\n"
|
||||
+ ");\n"
|
||||
+ "CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_upload_journal_locator ON collaboration_upload_journal (locator_hash);\n"
|
||||
+ "CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_upload_journal_intake ON collaboration_upload_journal (intake_key);\n"
|
||||
+ "CREATE INDEX IF NOT EXISTS idx_collaboration_upload_journal_open ON collaboration_upload_journal (state, id);\n"
|
||||
+ "CREATE INDEX IF NOT EXISTS idx_collaboration_upload_journal_parent ON collaboration_upload_journal (table_name, record_id);\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PostgreSQL 17 schema for the native postgres backend. Same columns, CHECK
|
||||
** guards and unique/ordinary indexes as the H2 script; instants are
|
||||
** TIMESTAMPTZ so a lease fence round-trips independent of the JVM zone.
|
||||
** Default (deterministic) collation keeps identifiers exact. Statements are
|
||||
** idempotent, so the script is safe to re-run.
|
||||
*******************************************************************************/
|
||||
public static String postgresScript()
|
||||
{
|
||||
return (h2Script().replace("TIMESTAMP NOT NULL,", "TIMESTAMPTZ NOT NULL,").replace("lease_until TIMESTAMP,", "lease_until TIMESTAMPTZ,"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MySQL 8.4 schema. Binary NO PAD comparison preserves exact identifiers;
|
||||
** millisecond timestamps preserve lease fences across connection round trips.
|
||||
*******************************************************************************/
|
||||
public static String mysqlScript()
|
||||
{
|
||||
return ("CREATE TABLE IF NOT EXISTS collaboration_upload_journal (\n"
|
||||
+ " id INTEGER AUTO_INCREMENT PRIMARY KEY,\n"
|
||||
+ " create_date TIMESTAMP(3) NOT NULL,\n"
|
||||
+ " modify_date TIMESTAMP(3) NOT NULL,\n"
|
||||
+ " table_name VARCHAR(128) NOT NULL,\n"
|
||||
+ " record_id VARCHAR(64) NOT NULL,\n"
|
||||
+ " created_by_user_id VARCHAR(255) NOT NULL,\n"
|
||||
+ " file_name VARCHAR(255) NOT NULL,\n"
|
||||
+ " content_type VARCHAR(255) NOT NULL,\n"
|
||||
+ " description VARCHAR(4096),\n"
|
||||
+ " size_bytes BIGINT NOT NULL,\n"
|
||||
+ " sha256 VARCHAR(64) NOT NULL,\n"
|
||||
+ " bucket_name VARCHAR(255) NOT NULL,\n"
|
||||
+ " storage_key VARCHAR(1024) NOT NULL,\n"
|
||||
+ " retained BOOLEAN NOT NULL,\n"
|
||||
+ " intake_key VARCHAR(64),\n"
|
||||
+ " intake_fingerprint VARCHAR(64),\n"
|
||||
+ " locator_hash VARCHAR(64) NOT NULL,\n"
|
||||
+ " manifest_hash VARCHAR(64) NOT NULL,\n"
|
||||
+ " state VARCHAR(16) NOT NULL,\n"
|
||||
+ " storage_state VARCHAR(16) NOT NULL,\n"
|
||||
+ " attachment_id INTEGER,\n"
|
||||
+ " lease_token VARCHAR(64),\n"
|
||||
+ " lease_until TIMESTAMP(3),\n"
|
||||
+ " fence BIGINT NOT NULL,\n"
|
||||
+ " CONSTRAINT ck_upload_journal_retained CHECK (retained = TRUE),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_state CHECK (state IN ('PREPARED', 'REGISTERED', 'COMPLETE')),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_storage CHECK (storage_state IN ('PENDING', 'UNKNOWN', 'VERIFIED')),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_intake CHECK ((intake_key IS NULL) = (intake_fingerprint IS NULL)),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_registered CHECK (state = 'PREPARED' OR attachment_id IS NOT NULL),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_verified CHECK (state = 'PREPARED' OR storage_state = 'VERIFIED'),\n"
|
||||
+ " UNIQUE KEY uq_collaboration_upload_journal_locator (locator_hash),\n"
|
||||
+ " UNIQUE KEY uq_collaboration_upload_journal_intake (intake_key),\n"
|
||||
+ " KEY idx_collaboration_upload_journal_open (state, id),\n"
|
||||
+ " KEY idx_collaboration_upload_journal_parent (table_name, record_id)\n"
|
||||
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin;\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded read-only string field.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData string(String name, String label, Integer length)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withMaxLength(length).withIsEditable(false));
|
||||
}
|
||||
}
|
||||
-63
@@ -1,63 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
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.expressions.CurrentUser;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Small metadata contract for native recovery navigation from a source's Files
|
||||
** panel. The client adds canonical source table/id pins to this server-owned view.
|
||||
** Filters aid discovery only; the journal still authorizes every row and action.
|
||||
*******************************************************************************/
|
||||
public final class UploadRecoveryNavigation
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata utility.
|
||||
*******************************************************************************/
|
||||
private UploadRecoveryNavigation()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention owners opt in their managed source tables after producing them.
|
||||
** No client knows or guesses the journal table or recovery process name.
|
||||
*******************************************************************************/
|
||||
public static void configure(QTableMetaData source)
|
||||
{
|
||||
Map<String, Serializable> view = new LinkedHashMap<>();
|
||||
view.put("tableName", UploadJournalTable.NAME);
|
||||
view.put("label", "Review interrupted uploads");
|
||||
view.put("filter", new QQueryFilter().withCriteria("createdByUserId", QCriteriaOperator.EQUALS, new CurrentUser())
|
||||
.withCriteria("state", QCriteriaOperator.IN, "PREPARED", "REGISTERED"));
|
||||
RecordFeaturesMetaData.ofOrWithNew(source).withSetting(RecordFeature.ATTACHMENTS, "recoveryView", (Serializable) view);
|
||||
}
|
||||
}
|
||||
-125
@@ -1,125 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable write reservation: an exclusive, fenced lease on one journal row.
|
||||
** Every mutating journal call re-checks the token, fence and expiry under a row
|
||||
** lock, so a holder that lost its lease cannot act. The token is generated by
|
||||
** the journal, is never persisted outside the journal row, never printed, and
|
||||
** is not accepted from clients. The snapshot is as of the grant.
|
||||
*******************************************************************************/
|
||||
public final class UploadReservation
|
||||
{
|
||||
private final UploadEntry entry;
|
||||
private final String leaseToken;
|
||||
private final Long fence;
|
||||
private final Instant leaseUntil;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-local; only the journal grants reservations.
|
||||
*******************************************************************************/
|
||||
UploadReservation(UploadEntry entry, String leaseToken, Long fence, Instant leaseUntil)
|
||||
{
|
||||
this.entry = entry;
|
||||
this.leaseToken = leaseToken;
|
||||
this.fence = fence;
|
||||
this.leaseUntil = leaseUntil;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal row id.
|
||||
*******************************************************************************/
|
||||
public Integer journalId()
|
||||
{
|
||||
return (entry.id());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The preserved manifest.
|
||||
*******************************************************************************/
|
||||
public UploadCandidate candidate()
|
||||
{
|
||||
return (entry.candidate());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal state as of the grant; read the journal for current state.
|
||||
*******************************************************************************/
|
||||
public UploadEntry entryAtGrant()
|
||||
{
|
||||
return (entry);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fencing counter of this grant.
|
||||
*******************************************************************************/
|
||||
public Long fence()
|
||||
{
|
||||
return (fence);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lease expiry of this grant.
|
||||
*******************************************************************************/
|
||||
public Instant leaseUntil()
|
||||
{
|
||||
return (leaseUntil);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secret proof of ownership, visible only to the journal implementation.
|
||||
*******************************************************************************/
|
||||
String leaseToken()
|
||||
{
|
||||
return (leaseToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never prints the token.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("UploadReservation[journalId=" + entry.id() + ", fence=" + fence + ", leaseUntil=" + leaseUntil + "]");
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +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.collaboration.attachments.uploadjournal;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What the journal knows about the original object. The journal never reads or
|
||||
** writes bytes; the storage owner reports these facts. Never moves backwards.
|
||||
*******************************************************************************/
|
||||
public enum UploadStorageState
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No write has been attempted that the journal knows of.
|
||||
*******************************************************************************/
|
||||
PENDING,
|
||||
|
||||
/*******************************************************************************
|
||||
** A write was attempted and its outcome is not known (timeout, crash). The
|
||||
** object may exist; it stays visible and reconcilable and is never deleted.
|
||||
*******************************************************************************/
|
||||
UNKNOWN,
|
||||
|
||||
/*******************************************************************************
|
||||
** The storage owner verified the exact bytes and hash of the original.
|
||||
*******************************************************************************/
|
||||
VERIFIED
|
||||
}
|
||||
+2
-2
@@ -83,7 +83,7 @@ public final class CustomFieldStorage
|
||||
}
|
||||
if(!postgres(table))
|
||||
{
|
||||
return (new Availability(false, "Backend does not support JsonPathStorage; EAV fields support read-only values and filters."));
|
||||
return (new Availability(false, "Backend does not support JsonPathStorage; EAV fields are read-only filters (values are not selected)."));
|
||||
}
|
||||
try(Connection connection = connection(table); var statement = connection.createStatement())
|
||||
{
|
||||
@@ -92,7 +92,7 @@ public final class CustomFieldStorage
|
||||
}
|
||||
catch(SQLException | IllegalArgumentException e)
|
||||
{
|
||||
return (new Availability(false, "JSON column unavailable; EAV fields support read-only values and filters. " + e.getMessage()));
|
||||
return (new Availability(false, "JSON column unavailable; EAV fields are read-only filters. " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-101
@@ -1,101 +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.collaboration.customfields;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.query.VirtualFieldValueProviderInterface;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
/*******************************************************************************
|
||||
** Supply EAV values for the parent records selected by the core query pipeline.
|
||||
** Do not re-read parents here: that would recursively execute this provider.
|
||||
*******************************************************************************/
|
||||
public class CustomFieldValueProvider implements VirtualFieldValueProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Bound each lookup and retain the caller's transaction through service storage.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void fill(QTableMetaData table, String fieldName, List<QRecord> records, QueryInput input) throws QException
|
||||
{
|
||||
String name = fieldName.substring(3);
|
||||
var definitions = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter()
|
||||
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("name", QCriteriaOperator.EQUALS, name)
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"), input.getTransaction());
|
||||
if(definitions.size() != 1)
|
||||
{
|
||||
throw (new QException("Custom field is unavailable"));
|
||||
}
|
||||
String column = FieldCriteriaExpander.valueColumn(definitions.getFirst().getValueString("type"));
|
||||
for(List<QRecord> page : CollectionUtils.getPages(records, 500))
|
||||
{
|
||||
List<String> ids = page.stream().map(record -> String.valueOf(record.getValue(table.getPrimaryKeyField()))).distinct().toList();
|
||||
Map<String, QRecord> values = new HashMap<>();
|
||||
for(QRecord value : valuesForSelectedParents(new QQueryFilter()
|
||||
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("fieldName", QCriteriaOperator.EQUALS, name)
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false).withCriteria("recordId", QCriteriaOperator.IN, ids), input))
|
||||
{
|
||||
if(values.put(value.getValueString("recordId"), value) != null)
|
||||
{
|
||||
throw (new QException("Duplicate custom field values"));
|
||||
}
|
||||
}
|
||||
for(QRecord record : page)
|
||||
{
|
||||
QRecord value = values.get(String.valueOf(record.getValue(table.getPrimaryKeyField())));
|
||||
record.setValue(fieldName, value == null ? null : ValueUtils.getValueAsFieldType(table.getVirtualField(fieldName).getType(), value.getValue(column)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The enclosing query already selected these parents under its access policy.
|
||||
** Remove only recursive parent verification on a private child metadata clone;
|
||||
** preserve child locks and never install this metadata on the instance.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> valuesForSelectedParents(QQueryFilter filter, QueryInput origin) throws QException
|
||||
{
|
||||
QTableMetaData metadata = QContext.getQInstance().getTable(CollaborationTables.FIELD_VALUE).clone();
|
||||
metadata.withCustomizers(null);
|
||||
metadata.getSupplementalMetaData().remove(RecordReferenceTableMetaData.NAME);
|
||||
QueryInput input = new QueryInput(CollaborationTables.FIELD_VALUE).withFilter(filter)
|
||||
.withShouldFetchHeavyFields(true).withTransaction(origin.getTransaction());
|
||||
input.setTableMetaData(metadata);
|
||||
input.withPermissionBypass("EAV provider reads only parents selected by the enclosing query.");
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
}
|
||||
+2
-11
@@ -43,7 +43,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueRangeBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
@@ -59,10 +58,7 @@ public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
|
||||
@Override
|
||||
public RuntimeMetaDataContribution getContribution(QInstance instance) throws QException
|
||||
{
|
||||
if(com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig.forInstance(instance) == null)
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(instance);
|
||||
}
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(instance);
|
||||
var result = new RuntimeMetaDataContribution();
|
||||
var overrides = new FeatureSettingsProvider().getOverrides(instance);
|
||||
List<QRecord> definitions = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION,
|
||||
@@ -123,15 +119,10 @@ public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
|
||||
String name = "cf_" + definition.getValueString("name");
|
||||
QFieldType type = type(definition.getValueString("type"));
|
||||
QFieldMetaData field = json ? new QFieldMetaData(name, type).withBackendStorage(new JsonPathStorage().withColumnName("qqq_custom_fields").withKey(definition.getValueString("name")))
|
||||
: new QVirtualFieldMetaData(name, type).withIsEditable(false).withIsQueryCriteria(true).withIsQuerySelectable(true)
|
||||
.withValueProvider(new QCodeReference(CustomFieldValueProvider.class))
|
||||
: new QVirtualFieldMetaData(name, type).withIsEditable(false).withIsQueryCriteria(true).withIsQuerySelectable(false)
|
||||
.withCriteriaExpander(new QCodeReference(FieldCriteriaExpander.class));
|
||||
field.withLabel(definition.getValueString("label")).withIsRequired(json && Boolean.TRUE.equals(definition.getValueBoolean("required")))
|
||||
.withMaxLength(definition.getValueInteger("maxLength"));
|
||||
if(type == QFieldType.STRING && definition.getValueInteger("maxLength") != null)
|
||||
{
|
||||
field.withBehavior(ValueTooLongBehavior.ERROR);
|
||||
}
|
||||
if(definition.getValue("defaultValue") != null && json)
|
||||
{
|
||||
field.withDefaultValue(com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(type, definition.getValue("defaultValue")));
|
||||
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user