Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdcf7ee365
|
||
|
|
2ac067bea4
|
||
|
|
53e51029a2
|
||
|
|
9c0e7a4afa
|
||
|
|
15cb8cbf72
|
||
|
|
576f078c48
|
||
|
|
69f7caf346
|
||
|
|
c3182e2cbc
|
||
|
|
8f348daf32
|
||
|
|
284a7ac5b6
|
||
|
|
221ec83457
|
No files matched your search
@@ -1,14 +1,16 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.11
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.11
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
only:
|
||||
- /^v\d+\.\d+\.\d+$/
|
||||
- /publish.*/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@7d73ac90b6b5a6acb61b4d3fa56625c5b6dcc652
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.11
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
@@ -10,6 +10,7 @@ release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
feature: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit Collaboration**: record collaboration for QQQ. Phase a implements feature settings, people tables and the app home. Design: [QRun-IO/qqq#1136](https://github.com/QRun-IO/qqq/pull/1136) (`docs/COLLABORATION.md`). Phases b to i are planned.
|
||||
**QBit Collaboration**: record collaboration for QQQ. The implemented feature set and published candidate deployment contract are documented in README.md and docs/CANDIDATE-RELEASE.md. Historical phase design: docs/DESIGN.md.
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -23,7 +23,7 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
- The home reads real data with the current session.
|
||||
- People are a service-managed directory; direct user writes are denied.
|
||||
- Feature settings are audited at FIELD level; writes require `collaboration.admin`.
|
||||
- Runtime override application and record feature services await the core contracts.
|
||||
- Runtime overrides and record feature services use the coordinated core contracts; preserve authorization, transaction boundaries and recipient field filtering.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
@@ -42,12 +42,12 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- 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.
|
||||
- Read pom.xml for the current build parent. The default-active qqq-snapshot profile pins the coordinated published core BOM; docs/CANDIDATE-RELEASE.md records compatible Redis, session-store and frontend versions.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
- CircleCI qqq-orb 0.6.8.
|
||||
- CircleCI is centrally owned through .qqq-ci.yml and the canonical setup caller. Keep both declarations aligned; validate with the selected orb canonical validator as well as CircleCI schema validation.
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- A `release/*` branch publishes RCs (the orb bumps the version).
|
||||
- A `v*` tag publishes the release.
|
||||
- Stable release tags and feature publish tags follow the manifest policy. Feature snapshots use the existing publish-tag automation; keep generated versions within the framework length limit. Do not treat snapshot publication as GA or production deployment.
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
All notable changes are documented here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic versioning.
|
||||
|
||||
## Coordinated candidate — 2026-10-21
|
||||
|
||||
Published `0.1.0-collab-8f348da-SNAPSHOT` with core `4.1.0-collab-33464d37e-SNAPSHOT` and Redis `0.1.0-redis-7fe5492-SNAPSHOT`. See [candidate release guide](docs/CANDIDATE-RELEASE.md) for the paired frontend/session-store and migration requirements.
|
||||
|
||||
- Added server-issued, ordered presence leases, shared Redis record/table state, explicit unavailable/expired responses and recipient-aware field filtering.
|
||||
- Preserved omitted assignment roles and prevented fallback ownership from overriding configured native ownership.
|
||||
- Added delivery acknowledgement semantics, transaction-aware attachment compensation and authorized mention-read flow.
|
||||
- Verified 177 release-source tests, coverage, remote CI, published-artifact consumer resolution and live cross-node presence/admin/health with the paired frontend.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# QBit Collaboration
|
||||
|
||||
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.
|
||||
Record collaboration for QQQ: attachments, tags, custom fields, comments and mentions, presence, sharing, favorites, follow, timeline, tasks, assignments and My Work. Feature settings, people directories, permissions and the app home use the standard QQQ framework. The coordinated candidate includes issued presence leases with optional shared Redis, recipient-aware field filtering, partial assignment updates, delivery acknowledgements and transaction-aware attachment cleanup.
|
||||
|
||||
Published versions, verified behavior and deployment requirements: [Coordinated candidate](docs/CANDIDATE-RELEASE.md). Historical phase references below describe implementation history, not the current release sequence.
|
||||
|
||||
Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md).
|
||||
|
||||
@@ -242,7 +244,7 @@ Uploads check the parent and `attach`, count bytes and compute SHA-256 while str
|
||||
|
||||
File names are reduced to a safe basename (maximum 255 characters). Object keys are `attachments/{table}/{recordId}/{uuid}/{fileName}`; table/id segments are URL-encoded to protect string primary keys containing slashes. Renames change the display name and subsequent download filename, retaining the original storage key. `AttachmentEntry` returns display metadata, creation time and `canDelete` (author or admin); it omits bucket/key and signed URLs. Lists are newest first. Changing or deleting an own row also rechecks `attach`; admins may moderate another author's row but must still see its parent. A batch authorizes all selected rows before deleting its first object.
|
||||
|
||||
**Permission model.** The panel service checks the parent with an enforced `GetAction` under the caller's session. Missing, unreadable and locked parents produce `QNotFoundException("Not found")`. Upload requires `<permissionBaseName>.attach` or table EDIT plus a visible parent. This is the explicitly temporary approximation of write-lock checks; distinct WRITE-only parent locks are not enforced until core's record-reference helper is released. Explicit `attach` grants work without EDIT. Collaboration tables cannot themselves be parents.
|
||||
**Permission model.** The panel service checks the parent with an enforced `GetAction` under the caller's session. Missing, unreadable and locked parents produce `QNotFoundException("Not found")`. Upload requires `<permissionBaseName>.attach` or table EDIT plus a visible parent. Core RecordReferenceSecurityHelper enforces parent visibility, and RecordFeaturePermissions applies WRITE locks for implied grants. Explicit `attach` grants work without EDIT. Collaboration tables cannot themselves be parents.
|
||||
|
||||
The admin Attachments table additionally requires `collaboration.view` (or `collaboration.admin`). The panel service bypasses that browse grant only after its own parent/feature/ownership checks. Direct table insert/update/delete is denied; use the service and record action so storage and rows remain coordinated. Storage operations use `ObjectStorage.asSystem("collaboration", runId).bucket(alias)` after these checks, preserving calling-qbit/initiating-user operation logging without requiring users to hold `object_storage.*` grants.
|
||||
|
||||
@@ -252,7 +254,7 @@ Core reference metadata now restricts parent tables/features and enforces parent
|
||||
|
||||
**Actions and cleanup.** `Delete attachments` is a standard streamed ETL record action with Review/Result counts, skipped unauthorized rows, permission rechecks at execution and per-row failure summaries. It is not in navigation. Soft deletion follows the bucket's versioning/trash; without either, deletion is permanent. There is deliberately no **Attach files** table action: uploads come through the API/panel. The current browse action requires `collaboration.view`; the panel may call the service without that grant.
|
||||
|
||||
After a parent deletion commits, trusted host code can call `new AttachmentCleanup().onParentsDeleted(table, ids)`. It deletes objects and rows in batches and is retryable. It cannot check an already-deleted parent and must never be exposed as a user endpoint. Wiring to core's `RecordReferenceCleanupInterface` waits for its release. UI API endpoints use the merged #1128 extension point from the locally installed snapshot; direct/presigned upload endpoints and realtime events remain future integrations. Upload/delete now write FILE_ADDED/FILE_REMOVED activity. The table declares `fileName`/`description` search fields and uses normal QQQ writes, so host-configured record-change/ESB listeners receive events; this qbit does not install an ESB publication or Quick Search provider.
|
||||
After a parent deletion commits, trusted host code can call `new AttachmentCleanup().onParentsDeleted(table, ids)`. It deletes objects and rows in batches and is retryable. It cannot check an already-deleted parent and must never be exposed as a user endpoint. CollaborationCleanup implements core's `RecordReferenceCleanupInterface`. The versioned UI API provides multipart upload and authorized download/preview URL responses using the coordinated published core. Outer transactions defer object deletion until commit; uploads require confirmed rollback-callback support. Durable crash/retry qualification remains tracked in QQQ#1289. Upload/delete now write FILE_ADDED/FILE_REMOVED activity. The table declares `fileName`/`description` search fields and uses normal QQQ writes, so host-configured record-change/ESB listeners receive events; this qbit does not install an ESB publication or Quick Search provider.
|
||||
|
||||
Tests use real QQQ memory actions and record locks plus an in-memory `AttachmentStorage` adapter. Object storage RC.2 exposes no fake bucket; its provider conformance suite uses MinIO/Testcontainers. This suite requires no Docker and does not claim MinIO/S3 conformance.
|
||||
|
||||
@@ -339,7 +341,7 @@ The guardrail allowlist properties retain the object-storage API and `*-api` art
|
||||
2. For PostgreSQL, generate `CollaborationSchema.postgresScript(config)` and apply it with the host's migration tool. It creates tables and unique indexes, never executes DDL, and does not migrate existing schemas. QQQ owns date/default values on writes.
|
||||
3. Grant `collaboration.view` and `collaboration.admin` as appropriate; wire the host's audit persistence for FIELD audit events.
|
||||
4. Validate the assembled instance. Navigate to Collaboration for Comments, Attachments, Activity (admins), Feature Settings and People. People populate as signed-in users call collaboration services.
|
||||
5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. Favorites, recents, attachments, comments, follow and timeline services can be used now. The versioned UI API is enabled by default; host-table overlays remain separate and scheduled work is opt-in.
|
||||
5. Use the coordinated versions in docs/CANDIDATE-RELEASE.md, enable the desired record features in host metadata, and configure their dependencies. The versioned UI API is enabled by default; scheduled work remains opt-in. Apply the documented host registration and schema migrations before rollout.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -712,34 +714,23 @@ Archive drops the index concurrently while retaining values.
|
||||
There is no automatic migration receipt. The home shows definition/index status totals and the
|
||||
number of definitions with diagnostic messages.
|
||||
|
||||
## Presence (phase g)
|
||||
## Presence
|
||||
|
||||
Enable `RecordFeature.PRESENCE` on the table. The typed endpoints below
|
||||
`/qqq/v1/qbit/collaboration` are:
|
||||
Enable `RecordFeature.PRESENCE` on the relevant tables. For multiple web nodes, configure the shared Redis provider described in [REDIS-PRESENCE.md](docs/REDIS-PRESENCE.md). Use the paired versions and deployment requirements in [CANDIDATE-RELEASE.md](docs/CANDIDATE-RELEASE.md).
|
||||
|
||||
| Operation | Input |
|
||||
The shared provider uses server-issued authority:
|
||||
|
||||
| Operation | Behavior |
|
||||
| --- | --- |
|
||||
| `POST /presence` | `{connectionId, table, pk?, mode: "VIEWING"|"EDITING", fieldName?}` |
|
||||
| `DELETE /presence` | `{connectionId, table, pk?}` |
|
||||
| `GET /presence?table=...&pk=...` | Omit pk for a table snapshot |
|
||||
| `POST /presence/leases` | Authorizes the target and creates an invisible expiring reservation; returns its connectionId. |
|
||||
| `POST /presence/leases/renew` | Activates or renews an existing handle using an ordered sequence; includes viewing/editing mode and optional focused field. |
|
||||
| `DELETE /presence` | Performs ordered departure. A delayed renewal cannot reopen that handle. |
|
||||
| `GET /presence?table=...&pk=...` | Reads the authorized record snapshot; 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
|
||||
refused renewal removes the caller's previous lease. Connection ids are namespaced by user;
|
||||
they need not be active SSE connections, allowing polling clients to participate.
|
||||
All paths are under `/qqq/v1/qbit/collaboration`. Shared presence rejects legacy heartbeat upserts. Local legacy behavior remains for compatibility; local issued authority is suitable for a single node, not an HA replacement for shared Redis.
|
||||
|
||||
Renew on open/focus change and every 20 seconds. Leases expire after 45 seconds, with a one-second
|
||||
sweep and expiry checks on reads. The cap is 50,000 leases per pod: new targets are refused with
|
||||
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.
|
||||
Identity and tenant scope come from authenticated host context. Reads and renewals recheck permissions; recipient field filtering prevents focus-name disclosure. Tabs merge by user, editing takes precedence over viewing, and table snapshots omit focus. Redis stores presence metadata, never unsaved record contents.
|
||||
|
||||
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
|
||||
channels. Configure core's ESB bus for multiple pods. Delivery is best effort; clients renew and
|
||||
refetch after reconnecting.
|
||||
HTTP409 with `PRESENCE_LEASE_EXPIRED` requires a fresh lease; unavailable shared storage returns503 and an explicit unavailable UI state. It never silently falls back to an empty room or local state. The frontend releases leases on hide/unmount and preserves drafts through presence changes.
|
||||
|
||||
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 counts
|
||||
per table, and core record-feature summaries include the presence snapshot.
|
||||
Browser events use SSE; HTTP performs lease mutations; Redis Pub/Sub carries scoped invalidation hints. Route each stream's subscription requests to its owning node. The Redis admin dashboard and framework health checks expose the configured connection and subscription state. Presence is advisory; supported versioned database writes protect concurrent saves.
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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 or a killed-JVM crash test.
|
||||
|
||||
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. The tested ambiguous upload completes its write before throwing; a provider that completes a write after cleanup has already run remains outside this evidence.
|
||||
|
||||
Full Maven verification passes 191 tests with no failures, errors or skips, including packaging and coverage checks.
|
||||
|
||||
Issue #1289 remains open. Outstanding acceptance includes process loss before upload compensation is registered, database failure while recording compensation, late provider writes after timeout, and host migration/rollback. Do not interpret the deletion fix alone as completion of the full issue.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Coordinated collaboration snapshot
|
||||
|
||||
The main-line candidate is published. Use these versions together:
|
||||
|
||||
| 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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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).
|
||||
@@ -18,6 +18,9 @@
|
||||
<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>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -69,6 +72,23 @@
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-postgres</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-redis</artifactId>
|
||||
<version>0.1.0-redis-7fe5492-SNAPSHOT</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
@@ -225,14 +245,14 @@
|
||||
<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. -->
|
||||
<!-- Candidate requires core changes in the coordinated 4.1.0 SNAPSHOT. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-collab-33464d37e-SNAPSHOT</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -47,6 +47,7 @@ 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";
|
||||
@@ -297,6 +298,10 @@ 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);
|
||||
@@ -674,4 +679,21 @@ 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,6 +155,10 @@ 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();
|
||||
@@ -183,6 +187,7 @@ 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(FeatureSettingsDefaultsProcess.process());
|
||||
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
|
||||
{
|
||||
|
||||
@@ -95,6 +95,11 @@ 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 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_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(com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).snapshot(ref.tableName(), id).stream().map(viewer ->
|
||||
summary.setPresence(new com.kingsrook.qbits.collaboration.presence.PresenceService().snapshot(ref.tableName(), id).stream().map(viewer ->
|
||||
{
|
||||
java.util.Map<String, java.io.Serializable> entry = new java.util.LinkedHashMap<>();
|
||||
entry.put("userId", viewer.userId());
|
||||
|
||||
@@ -52,9 +52,10 @@ public final class CollaborationApi
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.1.0")
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.3.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)
|
||||
@@ -96,6 +97,7 @@ public final class CollaborationApi
|
||||
.withSpec(ListMyTasksSpec.class)
|
||||
.withSpec(PutAssignmentSpec.class)
|
||||
.withSpec(GetMyWorkSpec.class)
|
||||
.withSpec(MarkMentionsReadSpec.class)
|
||||
.withSpec(UploadAttachmentSpec.class));
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,8 @@ 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.NOT_FOUND, "Not found (including invisible parents)", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Service temporarily unavailable", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+78
-4
@@ -41,11 +41,67 @@ 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) implements ToSchema
|
||||
public record CollaborationPresenceRequestV1(String connectionId, String table, String pk, String mode, String fieldName, Long sequence) 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.
|
||||
*******************************************************************************/
|
||||
@@ -57,15 +113,24 @@ 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)))));
|
||||
"fieldName", text("Optional focused field on a record.", true),
|
||||
"sequence", sequenceSchema()))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave removes only this authenticated user's connection and target.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceLeaveV1(String connectionId, String table, String pk) implements ToSchema
|
||||
public record CollaborationPresenceLeaveV1(String connectionId, String table, String pk, Long sequence) 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.
|
||||
*******************************************************************************/
|
||||
@@ -75,7 +140,7 @@ 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)))));
|
||||
"pk", text("Optional parent key.", true), "sequence", sequenceSchema()))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,4 +187,13 @@ 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."));
|
||||
}
|
||||
}
|
||||
@@ -718,6 +718,9 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
new PresenceService().leave(body.connectionId(), required(body.table(), "table"), body.pk(), body.sequence());
|
||||
return (success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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())));
|
||||
new PresenceService().heartbeat(body.connectionId(), required(body.table(), "table"), body.pk(), body.mode(), body.fieldName(), body.sequence())));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
.assign(table(input), pk(input), body.ownerUserId(), body.assigneeUserId())));
|
||||
.assignRoles(table(input), pk(input), body.ownerUserId(), body.assigneeUserId(), input.getBody().keySet())));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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.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.ATTACHMENT_CLEANUP, 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,7 +267,29 @@ public final class CollaborationHome
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if(admin)
|
||||
{
|
||||
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
|
||||
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) ->
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("table", tableLabel(table));
|
||||
|
||||
@@ -53,15 +53,42 @@ 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().withValue("ownerUserId", ownerUserId).withValue("assigneeUserId", assigneeUserId);
|
||||
QRecord change = new QRecord();
|
||||
if(roles.contains("ownerUserId"))
|
||||
{
|
||||
change.setValue("ownerUserId", ownerUserId);
|
||||
}
|
||||
if(roles.contains("assigneeUserId"))
|
||||
{
|
||||
change.setValue("assigneeUserId", assigneeUserId);
|
||||
}
|
||||
QRecord row;
|
||||
if(old.isEmpty())
|
||||
{
|
||||
@@ -73,7 +100,7 @@ public class AssignmentService
|
||||
row = get(table, id).orElseThrow();
|
||||
}
|
||||
ActivityWriter.write(table, id, "ASSIGNED", "Record assignment changed", CollaborationTables.RECORD_ASSIGNMENT, row.getValueString("id"));
|
||||
if(assigneeUserId != null && (old.isEmpty() || !Objects.equals(old.get().getValueString("assigneeUserId"), assigneeUserId)))
|
||||
if(roles.contains("assigneeUserId") && assigneeUserId != null && (old.isEmpty() || !Objects.equals(old.get().getValueString("assigneeUserId"), assigneeUserId)))
|
||||
{
|
||||
new FollowService().ensureFollower(assigneeUserId, table, id, "ASSIGNED");
|
||||
if(!assigneeUserId.equals(PersonalRecords.currentUserId()))
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
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.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never erase bytes while metadata still exists, including nontransactional hosts.
|
||||
** Provider or queue deletion failure retains the intent for idempotent replay.
|
||||
*******************************************************************************/
|
||||
private boolean finish(QRecord pending) throws QException
|
||||
{
|
||||
if(!AttachmentRecords.query(new QQueryFilter()
|
||||
.withCriteria("bucketName", QCriteriaOperator.EQUALS, pending.getValue("bucketName"))
|
||||
.withCriteria("storageKey", QCriteriaOperator.EQUALS, pending.getValue("storageKey"))).isEmpty())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
storage.delete(pending.getValueString("bucketName"), pending.getValueString("storageKey"));
|
||||
DeleteInput delete = new DeleteInput(CollaborationTables.ATTACHMENT_CLEANUP).withPrimaryKeys(List.of(pending.getValue("id")));
|
||||
delete.withPermissionBypass("Storage cleanup completed; remove its durable intent.");
|
||||
AttachmentRecords.checkErrors(new DeleteAction().execute(delete).getRecordsWithErrors());
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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()
|
||||
.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
|
||||
{
|
||||
if(finish(row))
|
||||
{
|
||||
completed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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."));
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-7
@@ -129,10 +129,33 @@ public class AttachmentsService
|
||||
}
|
||||
}
|
||||
String type = contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType;
|
||||
var transaction = QContext.getQBackendTransaction();
|
||||
if(transaction != null && !transaction.supportsRollbackCallbacks())
|
||||
{
|
||||
throw (new QException("Attachment uploads require a transaction with confirmed rollback support."));
|
||||
}
|
||||
try(InputStream upload = Files.newInputStream(temporary))
|
||||
{
|
||||
storage.put(bucket, PutRequest.of(key, upload).withContentLength(size).withContentType(type).withContentDisposition("inline"));
|
||||
}
|
||||
catch(IOException | RuntimeException failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
new AttachmentCleanupQueue(storage).compensateUpload(bucket, key);
|
||||
}
|
||||
catch(RuntimeException cleanupFailure)
|
||||
{
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
if(transaction != null)
|
||||
{
|
||||
var context = new com.kingsrook.qqq.backend.core.context.CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
transaction.addAfterRollbackCallback(() -> QContext.withTemporaryContext(context,
|
||||
() -> new AttachmentCleanupQueue(storage).compensateUpload(bucket, key)));
|
||||
}
|
||||
boolean rowSaved = false;
|
||||
try
|
||||
{
|
||||
@@ -154,7 +177,7 @@ public class AttachmentsService
|
||||
{
|
||||
if(!rowSaved)
|
||||
{
|
||||
storage.delete(bucket, key);
|
||||
new AttachmentCleanupQueue(storage).compensateUpload(bucket, key);
|
||||
}
|
||||
}
|
||||
catch(RuntimeException cleanupFailure)
|
||||
@@ -291,7 +314,7 @@ public class AttachmentsService
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete storage first so a provider failure leaves a retryable row.
|
||||
** Atomically remove metadata and retain a durable storage cleanup request.
|
||||
*******************************************************************************/
|
||||
void deleteStoredRow(QRecord row) throws QException
|
||||
{
|
||||
@@ -303,11 +326,7 @@ public class AttachmentsService
|
||||
*******************************************************************************/
|
||||
void deleteStoredRow(QRecord row, boolean cleanup) throws QException
|
||||
{
|
||||
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());
|
||||
new AttachmentCleanupQueue(storage).delete(row, cleanup);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -58,7 +58,10 @@ public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
|
||||
@Override
|
||||
public RuntimeMetaDataContribution getContribution(QInstance instance) throws QException
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(instance);
|
||||
if(com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig.forInstance(instance) == null)
|
||||
{
|
||||
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,
|
||||
|
||||
+15
-2
@@ -89,11 +89,20 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(CollaborationNotification n)
|
||||
{
|
||||
deliver(n);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A receipt means the queue accepted this event, including an existing duplicate.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean deliver(CollaborationNotification n)
|
||||
{
|
||||
EmailApiTemplate template = templateFor(n.category());
|
||||
if(template == null)
|
||||
{
|
||||
return;
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
@@ -101,7 +110,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
if(!StringUtils.hasContent(address))
|
||||
{
|
||||
LOG.info("Collaboration email skipped: no address for recipient", logPair("userId", n.recipientUserId()));
|
||||
return;
|
||||
return (false);
|
||||
}
|
||||
EmailApi.ensureTemplate(template);
|
||||
String eventId = n.sourceId() != null ? n.sourceId() : n.sourceRecordId();
|
||||
@@ -123,10 +132,12 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
LOG.info("Collaboration email not queued", logPair("userId", n.recipientUserId()), logPair("status", result.status()),
|
||||
logPair("messageId", result.messageId()));
|
||||
}
|
||||
return (result.status() == EmailApiStatus.QUEUED || result.status() == EmailApiStatus.DUPLICATE);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("Collaboration email failed", e, logPair("userId", n.recipientUserId()), logPair("category", n.category()));
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +194,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
{
|
||||
case MENTION -> "mention";
|
||||
case SHARE -> "share";
|
||||
case FOLLOW -> "follow";
|
||||
default -> "task";
|
||||
});
|
||||
}
|
||||
@@ -199,6 +211,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
case MENTION -> mentionTemplate();
|
||||
case SHARE -> shareTemplate();
|
||||
case TASK -> taskReminderTemplate();
|
||||
case FOLLOW -> definition("collaboration_follow", "Followed record changed", "Changes to {{recordLabel}}", "A record you follow has changed.\n\n[View record]({{link}})");
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,12 +193,17 @@ public class FollowChangeBatcher implements RecordChangeListenerInterface
|
||||
String user = batch.getValueString("userId");
|
||||
List<QRecord> follows = CollaborationRecords.query(CollaborationTables.FOLLOW,
|
||||
CollaborationRecords.parent(table, id).withCriteria("userId", QCriteriaOperator.EQUALS, user));
|
||||
if(!follows.isEmpty() && eligible(follows.getFirst(), now))
|
||||
if(!follows.isEmpty() && eligible(follows.getFirst(), now)
|
||||
&& new com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker().canRead(user, table, id).orElse(false))
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(user, "Changes to a followed record", batch.getValueString("changes"),
|
||||
CollaborationNotification.Category.FOLLOW, table, id, Notifications.link(table, id)));
|
||||
CollaborationNotification.Category.FOLLOW, table, id, Notifications.link(table, id)).withEvent(batch.getValue("id"), null),
|
||||
() -> CollaborationRecords.delete(CollaborationTables.FOLLOW_DIGEST, batch.getValue("id")));
|
||||
}
|
||||
else
|
||||
{
|
||||
CollaborationRecords.delete(CollaborationTables.FOLLOW_DIGEST, batch.getValue("id"));
|
||||
}
|
||||
CollaborationRecords.delete(CollaborationTables.FOLLOW_DIGEST, batch.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ public final class CollaborationTables
|
||||
public static final String ACTIVITY = "collaboration_activity";
|
||||
public static final String FOLLOW_DIGEST = "collaboration_follow_digest";
|
||||
public static final String ATTACHMENT = "collaboration_attachment";
|
||||
public static final String ATTACHMENT_CLEANUP = "collaboration_attachment_cleanup";
|
||||
public static final String FEATURE_SETTING = "collaboration_feature_setting";
|
||||
public static final String FAVORITE = "collaboration_favorite";
|
||||
public static final String RECENT = "collaboration_recent";
|
||||
@@ -202,6 +203,16 @@ public final class CollaborationTables
|
||||
tables.addAll(tagTables(config));
|
||||
tables.addAll(workTables(config));
|
||||
tables.addAll(customFieldTables(config));
|
||||
tables.add(base(config, ATTACHMENT_CLEANUP, "Attachment Cleanup", "delete")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
|
||||
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentCleanupQueue.class)))
|
||||
.withRecordLabelFormatAndFields("Cleanup %s", "id")
|
||||
.withField(new QFieldMetaData("attachmentId", QFieldType.INTEGER))
|
||||
.withField(string("bucketName", "Bucket", 255).withIsRequired(true))
|
||||
.withField(string("storageKey", "Storage Key", 1024).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("nextAttemptAt", QFieldType.DATE_TIME).withLabel("Next Attempt"))
|
||||
.withSection(new QFieldSection("cleanup", new QIcon("delete"), Tier.T1,
|
||||
List.of("id", "attachmentId", "bucketName", "storageKey", "nextAttemptAt")).withLabel("Cleanup")));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates"));
|
||||
|
||||
@@ -23,18 +23,19 @@ package com.kingsrook.qbits.collaboration.mywork;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qbits.collaboration.tasks.TasksService;
|
||||
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.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
@@ -43,18 +44,94 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
*******************************************************************************/
|
||||
public class MyWorkService
|
||||
{
|
||||
private static final int PAGE_SIZE = 200;
|
||||
private static final Comparator<QRecord> TASK_ORDER = Comparator.comparing((QRecord row) -> row.getValueInstant("dueAt"), Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(row -> row.getValueInteger("id"));
|
||||
|
||||
/*******************************************************************************
|
||||
** Open task count and first ten, assigned record counts, and unread mentions.
|
||||
*******************************************************************************/
|
||||
public Summary summary() throws QException
|
||||
{
|
||||
List<QRecord> tasks = new TasksService().mineRows("OPEN");
|
||||
String user = PersonalRecords.currentUserId();
|
||||
List<QRecord> tasks = new ArrayList<>();
|
||||
long taskCount = scanVisible(CollaborationTables.TASK, new QQueryFilter()
|
||||
.withCriteria("assigneeUserId", QCriteriaOperator.EQUALS, user)
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"), row ->
|
||||
{
|
||||
tasks.add(row);
|
||||
tasks.sort(TASK_ORDER);
|
||||
if(tasks.size() > 10)
|
||||
{
|
||||
tasks.removeLast();
|
||||
}
|
||||
});
|
||||
Map<String, Long> assignments = new LinkedHashMap<>();
|
||||
for(QRecord row : new AssignmentService().mineRows(null, true))
|
||||
scanVisible(CollaborationTables.RECORD_ASSIGNMENT, new QQueryFilter()
|
||||
.withCriteria("assigneeUserId", QCriteriaOperator.EQUALS, user),
|
||||
row -> assignments.merge(row.getValueString("tableName"), 1L, Long::sum));
|
||||
long mentions = scanVisible(CollaborationTables.MENTION, new QQueryFilter()
|
||||
.withCriteria("mentionedUserId", QCriteriaOperator.EQUALS, user)
|
||||
.withCriteria("notifiedAt", QCriteriaOperator.IS_NOT_BLANK).withCriteria("readAt", QCriteriaOperator.IS_BLANK), row ->
|
||||
{
|
||||
// Only the visible count is needed for mentions.
|
||||
});
|
||||
return (new Summary(taskCount, List.copyOf(tasks), assignments, mentions));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound retained rows and visibility decisions to one page. A starting high
|
||||
** watermark prevents concurrent inserts from extending this scan indefinitely.
|
||||
** Counts remain exact for visible rows scanned; no cross-request auth cache.
|
||||
*******************************************************************************/
|
||||
private long scanVisible(String table, QQueryFilter filter, java.util.function.Consumer<QRecord> consumer) throws QException
|
||||
{
|
||||
filter.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false);
|
||||
List<QRecord> last = CollaborationRecords.query(table, filter.clone().withOrderBy(new QFilterOrderBy("id", false)).withLimit(1));
|
||||
if(last.isEmpty())
|
||||
{
|
||||
assignments.merge(row.getValueString("tableName"), 1L, Long::sum);
|
||||
return (0);
|
||||
}
|
||||
int maximum = last.getFirst().getValueInteger("id");
|
||||
Integer cursor = null;
|
||||
long count = 0;
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter pageFilter = filter.clone().withCriteria("id", QCriteriaOperator.LESS_THAN_OR_EQUALS, maximum)
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(PAGE_SIZE);
|
||||
if(cursor != null)
|
||||
{
|
||||
pageFilter.withCriteria("id", QCriteriaOperator.GREATER_THAN, cursor);
|
||||
}
|
||||
List<QRecord> rows = CollaborationRecords.query(table, pageFilter);
|
||||
Map<List<String>, Boolean> parents = new HashMap<>();
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
List<String> parent = java.util.Arrays.asList(row.getValueString("tableName"), row.getValueString("recordId"));
|
||||
if(!parents.containsKey(parent))
|
||||
{
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireVisibleParent(parent.get(0), parent.get(1));
|
||||
parents.put(parent, true);
|
||||
}
|
||||
catch(QNotFoundException expected)
|
||||
{
|
||||
parents.put(parent, false);
|
||||
}
|
||||
}
|
||||
if(parents.get(parent))
|
||||
{
|
||||
consumer.accept(row);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if(rows.size() < PAGE_SIZE || rows.getLast().getValueInteger("id") >= maximum)
|
||||
{
|
||||
return (count);
|
||||
}
|
||||
cursor = rows.getLast().getValueInteger("id");
|
||||
}
|
||||
return (new Summary((long) tasks.size(), tasks.stream().limit(10).toList(), assignments, (long) unread().size()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -31,4 +31,13 @@ public interface CollaborationNotifier
|
||||
** Deliver a recipient-authorized notification.
|
||||
*******************************************************************************/
|
||||
void notify(CollaborationNotification n);
|
||||
|
||||
/*******************************************************************************
|
||||
** Legacy transports signal failure by throwing; adapters may explicitly refuse.
|
||||
*******************************************************************************/
|
||||
default boolean deliver(CollaborationNotification notification)
|
||||
{
|
||||
notify(notification);
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,13 @@ public class NoOpNotifier implements CollaborationNotifier
|
||||
public void notify(CollaborationNotification n)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No transport cannot acknowledge delivery.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean deliver(CollaborationNotification notification)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,10 @@ public final class Notifications
|
||||
if(!checkParent || new CollaborationRecordAccessChecker().canRead(notification.recipientUserId(), notification.sourceTableName(), notification.sourceRecordId()).orElse(false))
|
||||
{
|
||||
CollaborationNotifier notifier = notifier();
|
||||
notifier.notify(notification);
|
||||
if(!notifier.deliver(notification))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!(notifier instanceof NoOpNotifier))
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Ephemeral storage boundary behind the permission-checking presence service.
|
||||
*******************************************************************************/
|
||||
public interface PresenceBackend
|
||||
{
|
||||
/***************************************************************************
|
||||
** Reserve invisible renewal authority; null means capacity refusal.
|
||||
***************************************************************************/
|
||||
String openLease(String user, String name, String table, String pk) throws QException;
|
||||
|
||||
/***************************************************************************
|
||||
** Renew existing authority or throw PresenceLeaseExpiredException.
|
||||
***************************************************************************/
|
||||
boolean renewLease(String user, String name, String connection, String table, String pk, String mode, String field, Long sequence) throws QException;
|
||||
|
||||
/***************************************************************************
|
||||
** Renew a connection's ordered lease.
|
||||
***************************************************************************/
|
||||
boolean heartbeat(String user, String name, String connection, String table, String pk, String mode, String field, Long sequence) throws QException;
|
||||
|
||||
/***************************************************************************
|
||||
** Release only the authenticated user's connection.
|
||||
***************************************************************************/
|
||||
void leave(String user, String connection, String table, String pk, Long sequence) throws QException;
|
||||
|
||||
/***************************************************************************
|
||||
** Read a snapshot for subsequent recipient-specific permission filtering.
|
||||
***************************************************************************/
|
||||
List<PresenceRegistry.Viewer> snapshot(String table, String pk) throws QException;
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
/*******************************************************************************
|
||||
** Expired or absent renewal authority requires opening a fresh presence lease.
|
||||
*******************************************************************************/
|
||||
public final class PresenceLeaseExpiredException extends IllegalStateException
|
||||
{
|
||||
/***************************************************************************
|
||||
** Never include caller identifiers or stored data in this message.
|
||||
***************************************************************************/
|
||||
public PresenceLeaseExpiredException()
|
||||
{
|
||||
super("Presence lease expired; open a new lease");
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
|
||||
** Ephemeral per-pod presence replicated over a channel core refuses to clients.
|
||||
** Publish outside the monitor: the in-memory bus waits for listener threads.
|
||||
*******************************************************************************/
|
||||
public final class PresenceRegistry implements AutoCloseable
|
||||
public final class PresenceRegistry implements PresenceBackend, AutoCloseable
|
||||
{
|
||||
public static final String INTERNAL_CHANNEL = "instance:presence";
|
||||
public static final long TTL_MILLIS = 45000;
|
||||
@@ -119,7 +119,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
/*******************************************************************************
|
||||
** Each tab has its own lease; user views merge the live leases.
|
||||
*******************************************************************************/
|
||||
private record Entry(String displayName, String mode, String fieldName, long at)
|
||||
private record Entry(String displayName, String mode, String fieldName, long at, Long sequence)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -135,9 +135,21 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
*******************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName)
|
||||
{
|
||||
return (heartbeat(user, displayName, connection, table, pk, mode, fieldName, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Client sequence orders delayed HTTP requests independently of arrival time.
|
||||
*******************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName, Long sequence)
|
||||
{
|
||||
if(connection != null && connection.startsWith("lease:"))
|
||||
{
|
||||
throw (new PresenceLeaseExpiredException());
|
||||
}
|
||||
expire();
|
||||
var key = new Key(user, connection, table, pk);
|
||||
var entry = new Entry(displayName, mode, fieldName, clock.millis());
|
||||
var entry = new Entry(displayName, mode, fieldName, clock.millis(), sequence);
|
||||
if(!apply(key, entry, false))
|
||||
{
|
||||
return (false);
|
||||
@@ -146,13 +158,47 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
return (true);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Local mode issues local authority; HA applications must use shared Redis.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String openLease(String user, String displayName, String table, String pk)
|
||||
{
|
||||
expire();
|
||||
String connection = "lease:" + java.util.UUID.randomUUID();
|
||||
return (apply(new Key(user, connection, table, pk), new Entry(displayName, "PENDING", null, clock.millis(), 0L), false) ? connection : null);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Check renewal and update under one monitor; no late request creates a lease.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public boolean renewLease(String user, String displayName, String connection, String table, String pk, String mode, String fieldName, Long sequence)
|
||||
{
|
||||
if(connection == null || !connection.startsWith("lease:") || sequence == null || sequence < 1 || sequence > 9007199254740991L
|
||||
|| !List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode))
|
||||
{
|
||||
throw (new IllegalArgumentException("A server-issued lease, positive safe sequence and valid mode are required"));
|
||||
}
|
||||
expire();
|
||||
return (apply(new Key(user, connection, table, pk), new Entry(displayName, mode, fieldName, clock.millis(), sequence), false, true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Leaves use the same internal channel so every pod removes the lease.
|
||||
*******************************************************************************/
|
||||
public void leave(String user, String connection, String table, String pk)
|
||||
{
|
||||
leave(user, connection, table, pk, null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Departure participates in the same ordered client lease.
|
||||
*******************************************************************************/
|
||||
public void leave(String user, String connection, String table, String pk, Long sequence)
|
||||
{
|
||||
var key = new Key(user, connection, table, pk);
|
||||
var entry = new Entry(null, null, null, clock.millis());
|
||||
var entry = new Entry(null, null, null, clock.millis(), sequence);
|
||||
apply(key, entry, true);
|
||||
publish(key, entry, true);
|
||||
}
|
||||
@@ -162,6 +208,10 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
*******************************************************************************/
|
||||
private void publish(Key key, Entry entry, boolean leave)
|
||||
{
|
||||
if(key.connection().startsWith("lease:"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Map<String, Serializable> data = new LinkedHashMap<>();
|
||||
data.put("userId", key.user());
|
||||
data.put("connectionId", key.connection());
|
||||
@@ -172,6 +222,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
data.put("fieldName", entry.fieldName());
|
||||
data.put("at", entry.at());
|
||||
data.put("leave", leave);
|
||||
data.put("sequence", entry.sequence());
|
||||
bus.publish(new RealtimeMessage().withChannel(INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(data));
|
||||
}
|
||||
|
||||
@@ -192,41 +243,60 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
}
|
||||
expire();
|
||||
long receivedAt = Math.min(at.longValue(), clock.millis());
|
||||
// Best-effort replication cannot grant or restore issued renewal authority.
|
||||
if(connection.startsWith("lease:"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(receivedAt + TTL_MILLIS <= clock.millis())
|
||||
{
|
||||
return;
|
||||
}
|
||||
apply(new Key(user, connection, table, (String) data.get("pk")),
|
||||
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
|
||||
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt,
|
||||
data.get("sequence") instanceof Number sequence ? sequence.longValue() : null), Boolean.TRUE.equals(data.get("leave")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unchanged renewals extend TTL without generating client event traffic.
|
||||
*******************************************************************************/
|
||||
private boolean apply(Key key, Entry entry, boolean leave)
|
||||
{
|
||||
return (apply(key, entry, leave, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Local issued authority is checked atomically with the mutation.
|
||||
*******************************************************************************/
|
||||
private boolean apply(Key key, Entry entry, boolean leave, boolean requireLive)
|
||||
{
|
||||
boolean changed;
|
||||
synchronized(this)
|
||||
{
|
||||
Entry old = entries.get(key);
|
||||
if(old != null && old.at() > entry.at())
|
||||
if(requireLive && (old == null || old.mode() == null || old.at() + TTL_MILLIS <= clock.millis()))
|
||||
{
|
||||
throw (new PresenceLeaseExpiredException());
|
||||
}
|
||||
if(old != null && old.sequence() != null
|
||||
&& (entry.sequence() == null || entry.sequence() < old.sequence()
|
||||
|| (entry.sequence().equals(old.sequence()) && (!leave || old.mode() == null))))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(!leave && old == null && entries.size() >= capacity)
|
||||
if(old != null && old.sequence() == null && entry.sequence() == null
|
||||
&& (old.at() > entry.at() || (old.at() == entry.at() && old.mode() == null && !leave)))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(old == null && entries.size() >= capacity)
|
||||
{
|
||||
LOG.warn("Presence capacity reached; refusing a new target");
|
||||
return (false);
|
||||
}
|
||||
List<Viewer> before = snapshotInternal(key.table(), key.pk());
|
||||
if(leave)
|
||||
{
|
||||
entries.remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
entries.put(key, entry);
|
||||
}
|
||||
// Keep a bounded departure marker until old messages can no longer be fresh.
|
||||
entries.put(key, entry);
|
||||
changed = !before.equals(snapshotInternal(key.table(), key.pk()));
|
||||
}
|
||||
if(changed)
|
||||
@@ -277,7 +347,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
Map<String, Entry> users = new java.util.TreeMap<>();
|
||||
for(var row : entries.entrySet())
|
||||
{
|
||||
if(row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis())
|
||||
if(row.getValue().mode() != null && !"PENDING".equals(row.getValue().mode()) && row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis())
|
||||
{
|
||||
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
|
||||
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
/*******************************************************************************
|
||||
** Host resolves tenant scope from authenticated context, never request parameters.
|
||||
*******************************************************************************/
|
||||
public interface PresenceScopeProvider
|
||||
{
|
||||
/***************************************************************************
|
||||
** Return a stable nonblank scope; single-tenant hosts explicitly return one.
|
||||
***************************************************************************/
|
||||
String tenantScope();
|
||||
}
|
||||
@@ -30,19 +30,62 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.realtime.PresenceFieldVisibility;
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticated, permission-checked boundary for presence; nothing is persisted.
|
||||
*******************************************************************************/
|
||||
public class PresenceService
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Reserve invisible authority only for an authenticated, visible target.
|
||||
*******************************************************************************/
|
||||
public String openLease(String table, String pk) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
String canonical = authorize(table, pk);
|
||||
String name = QContext.getQSession().getUser().getFullName();
|
||||
String lease = registry().openLease(user, name == null || name.isBlank() ? user : name, table, canonical);
|
||||
if(lease == null)
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QServiceUnavailableException("Presence capacity reached; retry later"));
|
||||
}
|
||||
return (lease);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Issued renewals share the same identity, parent and field access checks.
|
||||
*******************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> renewLease(String connection, String table, String pk, String mode, String field, Long sequence) throws QException
|
||||
{
|
||||
requireIssuedLease(connection, sequence);
|
||||
return (renew(connection, table, pk, mode, field, sequence, true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recheck visibility on every renewal and remove a refused caller's old lease.
|
||||
*******************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> heartbeat(String connection, String table, String pk, String mode, String field) throws QException
|
||||
{
|
||||
return (heartbeat(connection, table, pk, mode, field, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered clients supply a monotonically increasing sequence per connection.
|
||||
*******************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> heartbeat(String connection, String table, String pk, String mode, String field, Long sequence) throws QException
|
||||
{
|
||||
return (renew(connection, table, pk, mode, field, sequence, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep legacy and issued requests on one permission and projection boundary.
|
||||
*******************************************************************************/
|
||||
private List<PresenceRegistry.Viewer> renew(String connection, String table, String pk, String mode, String field, Long sequence, boolean issued) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
validateConnection(connection);
|
||||
validateSequence(sequence);
|
||||
pk = canonicalKey(table, pk);
|
||||
try
|
||||
{
|
||||
@@ -50,34 +93,63 @@ public class PresenceService
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
registry().leave(user, connection, table, pk);
|
||||
try
|
||||
{
|
||||
registry().leave(user, connection, table, pk, sequence);
|
||||
}
|
||||
catch(QException cleanupFailure)
|
||||
{
|
||||
// Preserve the access refusal; an unreachable store expires the old lease.
|
||||
}
|
||||
throw (e);
|
||||
}
|
||||
if(!List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode))
|
||||
{
|
||||
throw (new QBadRequestException("Mode must be VIEWING or EDITING"));
|
||||
}
|
||||
if(field != null && (pk == null || QContext.getQInstance().getTable(table).getFields().get(field) == null))
|
||||
if(field != null && (pk == null || !PresenceFieldVisibility.visibleFieldNames(table).contains(field)))
|
||||
{
|
||||
throw (new QBadRequestException("Unknown focused field"));
|
||||
}
|
||||
String name = QContext.getQSession().getUser().getFullName();
|
||||
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, table, pk, mode, field))
|
||||
if(!issued && RedisPresenceConfig.forInstance(QContext.getQInstance()) != null)
|
||||
{
|
||||
throw (new QBadRequestException("Presence capacity reached; retry later"));
|
||||
throw (new QBadRequestException("Shared presence requires a server-issued lease; use the presence/leases endpoints"));
|
||||
}
|
||||
return (registry().snapshot(table, pk));
|
||||
String name = QContext.getQSession().getUser().getFullName();
|
||||
String displayName = name == null || name.isBlank() ? user : name;
|
||||
var backend = registry();
|
||||
boolean accepted = issued ? backend.renewLease(user, displayName, connection, table, pk, mode, field, sequence)
|
||||
: backend.heartbeat(user, displayName, connection, table, pk, mode, field, sequence);
|
||||
if(!accepted)
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QServiceUnavailableException("Presence capacity reached; retry later"));
|
||||
}
|
||||
return (visibleSnapshot(table, pk));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Namespacing by user prevents one user from removing another user's connection.
|
||||
*******************************************************************************/
|
||||
public void leave(String connection, String table, String pk) throws QException
|
||||
{
|
||||
leave(connection, table, pk, null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered departure cannot be undone by an earlier renewal arriving late.
|
||||
*******************************************************************************/
|
||||
public void leave(String connection, String table, String pk, Long sequence) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
validateConnection(connection);
|
||||
String canonical = authorize(table, pk);
|
||||
registry().leave(user, connection, table, canonical);
|
||||
validateSequence(sequence);
|
||||
if(connection.startsWith("lease:"))
|
||||
{
|
||||
requireIssuedLease(connection, sequence);
|
||||
}
|
||||
// Removal is scoped to the caller; revoked readers must still release their lease.
|
||||
String canonical = canonicalKey(table, pk);
|
||||
registry().leave(user, connection, table, canonical, sequence);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -86,7 +158,17 @@ public class PresenceService
|
||||
public List<PresenceRegistry.Viewer> snapshot(String table, String pk) throws QException
|
||||
{
|
||||
PersonalRecords.currentUserId();
|
||||
return (registry().snapshot(table, authorize(table, pk)));
|
||||
return (visibleSnapshot(table, authorize(table, pk)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project a fresh recipient view without mutating shared registry entries.
|
||||
*******************************************************************************/
|
||||
private List<PresenceRegistry.Viewer> visibleSnapshot(String table, String pk) throws QException
|
||||
{
|
||||
var visible = PresenceFieldVisibility.visibleFieldNames(table);
|
||||
return (registry().snapshot(table, pk).stream().map(viewer -> new PresenceRegistry.Viewer(viewer.userId(), viewer.displayName(), viewer.mode(),
|
||||
viewer.fieldName() != null && visible.contains(viewer.fieldName()) ? viewer.fieldName() : null)).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -111,10 +193,6 @@ public class PresenceService
|
||||
*******************************************************************************/
|
||||
private String canonicalKey(String tableName, String pk) throws QNotFoundException
|
||||
{
|
||||
if(pk == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
var table = QContext.getQInstance().getTable(tableName);
|
||||
@@ -122,6 +200,10 @@ public class PresenceService
|
||||
{
|
||||
throw (new IllegalArgumentException("Unknown table"));
|
||||
}
|
||||
if(pk == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), pk).toString());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
@@ -142,10 +224,39 @@ public class PresenceService
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All endpoints on a pod share the eagerly installed instance registry.
|
||||
** Issued handles require ordered activation and departure requests.
|
||||
*******************************************************************************/
|
||||
private PresenceRegistry registry()
|
||||
private void requireIssuedLease(String connection, Long sequence) throws QBadRequestException
|
||||
{
|
||||
validateConnection(connection);
|
||||
validateSequence(sequence);
|
||||
if(!connection.startsWith("lease:") || sequence == null || sequence < 1)
|
||||
{
|
||||
throw (new QBadRequestException("A server-issued lease and positive sequence are required"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Restrict ordered leases to exact nonnegative JavaScript integers.
|
||||
*******************************************************************************/
|
||||
private void validateSequence(Long sequence) throws QBadRequestException
|
||||
{
|
||||
if(sequence != null && (sequence < 0 || sequence > 9007199254740991L))
|
||||
{
|
||||
throw (new QBadRequestException("Presence sequence must be a nonnegative safe integer"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Select the configured backend without creating an unused local registry.
|
||||
*******************************************************************************/
|
||||
private PresenceBackend registry() throws QException
|
||||
{
|
||||
var config = RedisPresenceConfig.forInstance(QContext.getQInstance());
|
||||
if(config != null)
|
||||
{
|
||||
return (new RedisPresenceBackend(config));
|
||||
}
|
||||
return (PresenceRegistry.get(QContext.getQInstance()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import com.kingsrook.qbits.redis.api.RedisNamespace;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeAudienceProvider;
|
||||
|
||||
/*******************************************************************************
|
||||
** Presence hints are visible only within the authenticated storage namespace.
|
||||
*******************************************************************************/
|
||||
public class RedisPresenceAudience implements RealtimeAudienceProvider
|
||||
{
|
||||
public static final String EVENT = "presence.invalidated";
|
||||
|
||||
/***************************************************************************
|
||||
** Recompute recipient scope on every delivery and replay; never trust the hint.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public boolean allows(String audience) throws Exception
|
||||
{
|
||||
var config = RedisPresenceConfig.forInstance(QContext.getQInstance());
|
||||
return (config != null && namespace(config).key("audience").equals(audience));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Storage and delivery must derive exactly the same host-owned tenant scope.
|
||||
***************************************************************************/
|
||||
public static RedisNamespace namespace(RedisPresenceConfig config) throws Exception
|
||||
{
|
||||
var scope = QCodeLoader.getAdHoc(PresenceScopeProvider.class, config.getScopeProvider()).tenantScope();
|
||||
return (new RedisNamespace(config.getDeployment(), config.getApplication(), scope, "presence"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.redis.api.RedisAccess;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QServiceUnavailableException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Request-scoped Redis adapter; tenant identity is re-derived for every request.
|
||||
*******************************************************************************/
|
||||
public class RedisPresenceBackend implements PresenceBackend
|
||||
{
|
||||
private final RedisPresenceStore store;
|
||||
private final String audience;
|
||||
|
||||
/***************************************************************************
|
||||
** Require a running named pool and an explicit host-derived tenant namespace.
|
||||
***************************************************************************/
|
||||
public RedisPresenceBackend(RedisPresenceConfig config) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
var namespace = RedisPresenceAudience.namespace(config);
|
||||
audience = namespace.key("audience");
|
||||
var client = RedisAccess.scripts(QContext.getQInstance(), config.getConnectionName());
|
||||
store = new RedisPresenceStore(client, namespace, Duration.ofMillis(PresenceRegistry.TTL_MILLIS), config.getCapacity());
|
||||
}
|
||||
catch(Exception failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Reservations are invisible and do not require a client refresh hint.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String openLease(String user, String name, String table, String pk) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (store.openLease(user, name, table, pk));
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve expiry as a rejoin signal, distinct from infrastructure failure.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public boolean renewLease(String user, String name, String connection, String table, String pk, String mode, String field, Long sequence) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
boolean accepted = store.renewLease(user, name, connection, table, pk, mode, field, sequence);
|
||||
if(accepted)
|
||||
{
|
||||
changed(table, pk);
|
||||
}
|
||||
return (accepted);
|
||||
}
|
||||
catch(PresenceLeaseExpiredException failure)
|
||||
{
|
||||
throw (failure);
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Do not fall back to isolated local state when a shared store is unavailable.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public boolean heartbeat(String user, String name, String connection, String table, String pk, String mode, String field, Long sequence) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
boolean accepted = store.heartbeat(user, name, connection, table, pk, mode, field, sequence);
|
||||
if(accepted)
|
||||
{
|
||||
changed(table, pk);
|
||||
}
|
||||
return (accepted);
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** If unavailable, the existing lease still expires using Redis server time.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void leave(String user, String connection, String table, String pk, Long sequence) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!store.leave(user, connection, table, pk, sequence))
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
changed(table, pk);
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Failure is explicit; an empty result is reserved for a successfully read room.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<PresenceRegistry.Viewer> snapshot(String table, String pk) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (store.snapshot(table, pk));
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Never expose client exceptions, endpoint details or credentials to browsers.
|
||||
***************************************************************************/
|
||||
private static QServiceUnavailableException unavailable()
|
||||
{
|
||||
return (new QServiceUnavailableException("Presence is temporarily unavailable. Please retry."));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Best-effort hints carry identifiers only; authorized snapshots remain truth.
|
||||
***************************************************************************/
|
||||
private void changed(String table, String pk)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bus = com.kingsrook.qqq.backend.core.realtime.RealtimeBus.get(QContext.getQInstance());
|
||||
bus.publish(new com.kingsrook.qqq.backend.core.realtime.RealtimeMessage()
|
||||
.withChannel("table:" + table).withEvent(RedisPresenceAudience.EVENT).withAudience(audience)
|
||||
.withData(java.util.Map.of("table", table)));
|
||||
if(pk != null)
|
||||
{
|
||||
bus.publish(new com.kingsrook.qqq.backend.core.realtime.RealtimeMessage()
|
||||
.withChannel("record:" + table + ":" + pk).withEvent(RedisPresenceAudience.EVENT).withAudience(audience)
|
||||
.withData(java.util.Map.of("table", table, "pk", pk)));
|
||||
}
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.logging.QLogger.getLogger(RedisPresenceBackend.class)
|
||||
.warn("Presence hint delivery failed; clients must reconcile through authorized reads");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
/*******************************************************************************
|
||||
** Host configuration for shared presence, including authoritative tenant scope.
|
||||
*******************************************************************************/
|
||||
public class RedisPresenceConfig implements Serializable
|
||||
{
|
||||
private String connectionName = null;
|
||||
private String deployment = null;
|
||||
private String application = null;
|
||||
private QCodeReference scopeProvider = null;
|
||||
private Integer capacity = 2000;
|
||||
|
||||
/***************************************************************************
|
||||
** Inspect configuration without creating a runtime pool or local registry.
|
||||
***************************************************************************/
|
||||
public static RedisPresenceConfig forInstance(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance)
|
||||
{
|
||||
var config = instance.getQBits().values().stream().map(bit -> bit.getConfig())
|
||||
.filter(com.kingsrook.qbits.collaboration.CollaborationQBitConfig.class::isInstance)
|
||||
.map(com.kingsrook.qbits.collaboration.CollaborationQBitConfig.class::cast).findFirst().orElse(null);
|
||||
return (config == null ? null : config.getRedisPresence());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit scope prevents accidental sharing between deployments or tenants.
|
||||
***************************************************************************/
|
||||
public void validate(List<String> errors)
|
||||
{
|
||||
if(connectionName == null || connectionName.isBlank() || deployment == null || deployment.isBlank()
|
||||
|| application == null || application.isBlank() || scopeProvider == null)
|
||||
{
|
||||
errors.add("Redis presence requires a named connection, deployment, application and tenant scope provider");
|
||||
}
|
||||
if(capacity == null || capacity < 1 || capacity > 10000)
|
||||
{
|
||||
errors.add("Redis presence capacity must be between 1 and 10000");
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Return connectionName.
|
||||
***************************************************************************/
|
||||
public String getConnectionName()
|
||||
{
|
||||
return (connectionName);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Configure connectionName.
|
||||
***************************************************************************/
|
||||
public RedisPresenceConfig withConnectionName(String value)
|
||||
{
|
||||
connectionName = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Return deployment.
|
||||
***************************************************************************/
|
||||
public String getDeployment()
|
||||
{
|
||||
return (deployment);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Configure deployment.
|
||||
***************************************************************************/
|
||||
public RedisPresenceConfig withDeployment(String value)
|
||||
{
|
||||
deployment = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Return application.
|
||||
***************************************************************************/
|
||||
public String getApplication()
|
||||
{
|
||||
return (application);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Configure application.
|
||||
***************************************************************************/
|
||||
public RedisPresenceConfig withApplication(String value)
|
||||
{
|
||||
application = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Return scopeProvider.
|
||||
***************************************************************************/
|
||||
public QCodeReference getScopeProvider()
|
||||
{
|
||||
return (scopeProvider);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Configure scopeProvider.
|
||||
***************************************************************************/
|
||||
public RedisPresenceConfig withScopeProvider(QCodeReference value)
|
||||
{
|
||||
scopeProvider = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Return capacity.
|
||||
***************************************************************************/
|
||||
public Integer getCapacity()
|
||||
{
|
||||
return (capacity);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Configure capacity.
|
||||
***************************************************************************/
|
||||
public RedisPresenceConfig withCapacity(Integer value)
|
||||
{
|
||||
capacity = value;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.redis.api.RedisNamespace;
|
||||
import com.kingsrook.qbits.redis.api.RedisScript;
|
||||
import com.kingsrook.qbits.redis.api.RedisScriptExecutor;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared ephemeral presence. Callers must authorize identity, scope and fields.
|
||||
** Scripts atomically update record/table membership using the Redis server clock.
|
||||
*******************************************************************************/
|
||||
public final class RedisPresenceStore
|
||||
{
|
||||
private static final RedisScript MUTATE = new RedisScript(resource("redis-mutate.lua"));
|
||||
private static final RedisScript SNAPSHOT = new RedisScript(resource("redis-snapshot.lua"));
|
||||
private final RedisScriptExecutor client;
|
||||
private final RedisNamespace namespace;
|
||||
private final long ttlMillis;
|
||||
private final int capacity;
|
||||
|
||||
/***************************************************************************
|
||||
** Limit each indexed room, including departure markers, to bound script work.
|
||||
***************************************************************************/
|
||||
public RedisPresenceStore(RedisScriptExecutor client, RedisNamespace namespace, Duration ttl, int capacity)
|
||||
{
|
||||
this.client = Objects.requireNonNull(client);
|
||||
this.namespace = Objects.requireNonNull(namespace);
|
||||
this.ttlMillis = Objects.requireNonNull(ttl).toMillis();
|
||||
if(ttlMillis < 1 || ttlMillis > Duration.ofMinutes(5).toMillis() || capacity < 1 || capacity > 10000)
|
||||
{
|
||||
throw (new IllegalArgumentException("Presence requires a positive TTL of at most five minutes and capacity of 1 to 10000"));
|
||||
}
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Renew a connection; duplicate or delayed sequences never extend its lease.
|
||||
***************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String field, Long sequence)
|
||||
{
|
||||
if(connection != null && connection.startsWith("lease:"))
|
||||
{
|
||||
throw (new PresenceLeaseExpiredException());
|
||||
}
|
||||
if(!List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode))
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid presence mode"));
|
||||
}
|
||||
return (mutate(user, displayName, connection, table, pk, mode, field, sequence, false, "legacy"));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Reserve an invisible server-issued lease. Null means room capacity refused.
|
||||
** Only a subsequent renewal makes it visible; lost responses expire silently.
|
||||
***************************************************************************/
|
||||
public String openLease(String user, String displayName, String table, String pk)
|
||||
{
|
||||
String connection = "lease:" + java.util.UUID.randomUUID();
|
||||
return (mutate(user, displayName, connection, table, pk, "VIEWING", null, 0L, false, "open") ? connection : null);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Activate or renew existing authority, never recreate missing/departed state.
|
||||
** Callers must reauthorize identity, parent visibility and focused fields.
|
||||
***************************************************************************/
|
||||
public boolean renewLease(String user, String displayName, String connection, String table, String pk, String mode, String field, Long sequence)
|
||||
{
|
||||
if(connection == null || !connection.startsWith("lease:") || sequence == null || sequence < 1)
|
||||
{
|
||||
throw (new IllegalArgumentException("A server-issued lease and positive sequence are required"));
|
||||
}
|
||||
if(!List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode))
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid presence mode"));
|
||||
}
|
||||
return (mutate(user, displayName, connection, table, pk, mode, field, sequence, false, "renew"));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Retain a bounded departure marker so older HTTP renewals cannot undo leave.
|
||||
***************************************************************************/
|
||||
public boolean leave(String user, String connection, String table, String pk, Long sequence)
|
||||
{
|
||||
return (mutate(user, user, connection, table, pk, "VIEWING", null, sequence, true, "legacy"));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Redis failures propagate; they must never be presented as an empty room.
|
||||
***************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> snapshot(String table, String pk)
|
||||
{
|
||||
List<String> keys = roomKeys(table, pk, true);
|
||||
Object result = client.execute(SNAPSHOT, keys, List.of(pk == null ? "" : ":" + encode(pk, 1024)));
|
||||
Map<String, Stored> users = new TreeMap<>();
|
||||
for(Object value : (List<?>) result)
|
||||
{
|
||||
try
|
||||
{
|
||||
Stored entry = JsonUtils.toObject((String) value, Stored.class);
|
||||
if(!entry.departed() && !entry.pending())
|
||||
{
|
||||
users.merge(entry.userId(), entry, (old, next) ->
|
||||
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Invalid stored presence data", e));
|
||||
}
|
||||
}
|
||||
return (users.values().stream().map(entry -> new PresenceRegistry.Viewer(entry.userId(), entry.displayName(), entry.mode(),
|
||||
pk == null ? null : entry.fieldName())).toList());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Validate bounded inputs before sending any Lua command to Redis.
|
||||
***************************************************************************/
|
||||
private boolean mutate(String user, String displayName, String connection, String table, String pk, String mode, String field, Long sequence, boolean departed, String operation)
|
||||
{
|
||||
String member = encode(user, 255) + ":" + encode(connection, 255) + ":" + (pk == null ? "table" : encode(pk, 1024));
|
||||
encode(displayName, 1024);
|
||||
if(field != null)
|
||||
{
|
||||
encode(field, 255);
|
||||
}
|
||||
if(sequence != null && (sequence < 0 || sequence > 9007199254740991L))
|
||||
{
|
||||
throw (new IllegalArgumentException("Presence sequence must be a nonnegative safe integer"));
|
||||
}
|
||||
Stored value = new Stored(user, displayName, mode, pk == null ? null : field, 0, sequence, departed, false, false);
|
||||
Object result = client.execute(MUTATE, roomKeys(table, pk, true), List.of(member, JsonUtils.toJson(value),
|
||||
sequence == null ? "" : sequence.toString(), departed ? "1" : "0", Long.toString(ttlMillis), Integer.toString(capacity), pk == null ? "1" : "0", operation));
|
||||
if(((Number) result).longValue() == -1)
|
||||
{
|
||||
throw (new PresenceLeaseExpiredException());
|
||||
}
|
||||
return (((Number) result).longValue() == 1);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** One table hash tag keeps record/table updates atomic on Redis Cluster slots.
|
||||
***************************************************************************/
|
||||
private List<String> roomKeys(String table, String pk, boolean includeTable)
|
||||
{
|
||||
String base = "qqq:presence:{" + digest("SHA-256", namespace.key(table)) + "}";
|
||||
List<String> keys = new ArrayList<>();
|
||||
if(pk != null)
|
||||
{
|
||||
String record = base + ":record:" + encode(pk, 1024);
|
||||
keys.add(record + ":expiry");
|
||||
keys.add(record + ":state");
|
||||
}
|
||||
if(pk == null || includeTable)
|
||||
{
|
||||
keys.add(base + ":table:expiry");
|
||||
keys.add(base + ":table:state");
|
||||
}
|
||||
return (keys);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Distinguish arbitrary identifiers without allowing delimiter injection.
|
||||
***************************************************************************/
|
||||
private static String encode(String value, int maximumBytes)
|
||||
{
|
||||
if(value == null || value.isBlank() || value.getBytes(StandardCharsets.UTF_8).length > maximumBytes)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid or oversized presence identifier"));
|
||||
}
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Hash public routing identifiers and scripts using JDK algorithms.
|
||||
***************************************************************************/
|
||||
private static String digest(String algorithm, String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance(algorithm).digest(value.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("Required digest algorithm unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Load immutable scripts from the application artifact.
|
||||
***************************************************************************/
|
||||
private static String resource(String name)
|
||||
{
|
||||
try(var stream = RedisPresenceStore.class.getResourceAsStream("/presence/" + name))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IllegalStateException("Missing presence script"));
|
||||
}
|
||||
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Cannot load presence script", e));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Internal lease data, never returned directly to a browser.
|
||||
***************************************************************************/
|
||||
public record Stored(String userId, String displayName, String mode, String fieldName, long at, Long sequence, boolean departed, boolean pending, boolean leased)
|
||||
{
|
||||
}
|
||||
|
||||
}
|
||||
@@ -746,3 +746,19 @@ entries:
|
||||
summary: "Choose the record feature you want to configure."
|
||||
- key: possibleValueSource:collaboration_permission_mode;slot:help
|
||||
summary: "Choose whether a feature accepts editing permission or requires an explicit feature permission."
|
||||
- key: table:collaboration_attachment_cleanup;slot:help
|
||||
summary: "Review storage cleanup waiting to finish after an attachment was deleted."
|
||||
content: |
|
||||
**How rows get created:** Authorized attachment deletion creates these records automatically. The attachment disappears from the record immediately; its file is removed after the database commits. Successful cleanup removes this entry. If storage is unavailable, the entry remains for the scheduled retry. Administrators can inspect pending work; these records cannot be edited directly.
|
||||
- key: table:collaboration_attachment_cleanup;field:attachmentId
|
||||
summary: "The deleted attachment, when known; failed uploads may have no committed attachment ID."
|
||||
- key: table:collaboration_attachment_cleanup;field:bucketName
|
||||
summary: "The storage bucket containing the file awaiting cleanup."
|
||||
- key: table:collaboration_attachment_cleanup;field:storageKey
|
||||
summary: "The immutable file location retained until cleanup succeeds."
|
||||
- key: table:collaboration_attachment_cleanup;field:nextAttemptAt
|
||||
summary: "Failed cleanup waits at least one minute before another scheduled attempt."
|
||||
- key: process:collaborationRetryAttachmentCleanup;slot:help
|
||||
summary: "Retry pending attachment file cleanup after storage or database recovery."
|
||||
content: |
|
||||
Processes up to 100 committed cleanup requests. Completed entries are removed; failures remain available for retry. Runs every minute when the host configures a collaboration scheduler. Manual execution requires collaboration administrator access.
|
||||
@@ -703,6 +703,23 @@ components:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
CollaborationPresenceExpiredV1:
|
||||
properties:
|
||||
code:
|
||||
description: "Open a fresh lease before retrying renewal."
|
||||
enum:
|
||||
- "PRESENCE_LEASE_EXPIRED"
|
||||
nullable: false
|
||||
type: "string"
|
||||
type: "object"
|
||||
CollaborationPresenceLeaseV1:
|
||||
properties:
|
||||
connectionId:
|
||||
description: "Server-issued handle. Renew before 45 seconds; departure is\
|
||||
\ terminal."
|
||||
nullable: false
|
||||
type: "string"
|
||||
type: "object"
|
||||
CollaborationPresenceLeaveV1:
|
||||
description: "Remove the caller's lease on this connection and target."
|
||||
properties:
|
||||
@@ -714,6 +731,22 @@ components:
|
||||
description: "Optional parent key."
|
||||
nullable: true
|
||||
type: "string"
|
||||
sequence:
|
||||
description: "Monotonically increasing nonnegative safe integer per connection\
|
||||
\ and target; orders delayed requests."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
table:
|
||||
description: "Parent table name."
|
||||
nullable: false
|
||||
type: "string"
|
||||
type: "object"
|
||||
CollaborationPresenceOpenV1:
|
||||
properties:
|
||||
pk:
|
||||
description: "Optional record key."
|
||||
nullable: true
|
||||
type: "string"
|
||||
table:
|
||||
description: "Parent table name."
|
||||
nullable: false
|
||||
@@ -741,6 +774,11 @@ components:
|
||||
description: "Optional parent key; omit for a table view."
|
||||
nullable: true
|
||||
type: "string"
|
||||
sequence:
|
||||
description: "Monotonically increasing nonnegative safe integer per connection\
|
||||
\ and target; orders delayed requests."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
table:
|
||||
description: "Parent table name."
|
||||
nullable: false
|
||||
@@ -1250,7 +1288,7 @@ info:
|
||||
description: "Record collaboration: favorites, recents, attachments, tags, conversations,\
|
||||
\ following, timeline, people and presence"
|
||||
title: "QQQ Middleware API extension: collaboration"
|
||||
version: "1.1.0"
|
||||
version: "1.3.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/qbit/collaboration/presence:
|
||||
@@ -1289,6 +1327,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -1362,6 +1406,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -1428,6 +1478,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -1459,6 +1515,151 @@ paths:
|
||||
summary: "Renew presence"
|
||||
tags:
|
||||
- "collaboration"
|
||||
/qqq/v1/qbit/collaboration/presence/leases:
|
||||
post:
|
||||
description: "Open presence lease. Uses the signed-in caller's permissions and\
|
||||
\ record visibility."
|
||||
operationId: "postQbitCollaborationPresenceLeases"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationPresenceOpenV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationPresenceLeaseV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Open presence lease"
|
||||
tags:
|
||||
- "collaboration"
|
||||
/qqq/v1/qbit/collaboration/presence/leases/renew:
|
||||
post:
|
||||
description: "Renew issued presence lease. Uses the signed-in caller's permissions\
|
||||
\ and record visibility."
|
||||
operationId: "postQbitCollaborationPresenceLeasesRenew"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationPresenceRequestV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationPresenceSnapshotV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationPresenceExpiredV1"
|
||||
description: "Presence lease expired; open a new lease."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Renew issued presence lease"
|
||||
tags:
|
||||
- "collaboration"
|
||||
/qqq/v1/qbit/collaboration/favorites:
|
||||
delete:
|
||||
description: "Unstar a target. Uses the signed-in caller's permissions and record\
|
||||
@@ -1495,6 +1696,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -1569,6 +1776,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -1635,6 +1848,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -1710,6 +1929,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -1776,6 +2001,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -1843,6 +2074,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -1930,6 +2167,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2009,6 +2252,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2077,6 +2326,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2145,6 +2400,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2213,6 +2474,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2286,6 +2553,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2366,6 +2639,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2432,6 +2711,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2519,6 +2804,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2598,6 +2889,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2685,6 +2982,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2764,6 +3067,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2832,6 +3141,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2905,6 +3220,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -2973,6 +3294,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3041,6 +3368,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3122,6 +3455,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3215,6 +3554,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3289,6 +3634,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3362,6 +3713,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3441,6 +3798,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3528,6 +3891,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3628,6 +3997,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3702,6 +4077,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3770,6 +4151,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3844,6 +4231,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -3924,6 +4317,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -4004,6 +4403,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -4071,6 +4476,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -4146,6 +4557,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -4239,6 +4656,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -4318,6 +4741,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -4392,6 +4821,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -4473,6 +4908,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -4553,6 +4994,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -4614,6 +5061,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
@@ -4645,6 +5098,73 @@ paths:
|
||||
summary: "Get my work summary"
|
||||
tags:
|
||||
- "collaboration"
|
||||
/qqq/v1/qbit/collaboration/my-work/mentions/read:
|
||||
post:
|
||||
description: "Mark my visible mentions read. Uses the signed-in caller's permissions\
|
||||
\ and record visibility."
|
||||
operationId: "postQbitCollaborationMyWorkMentionsRead"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found (including invisible parents)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Service temporarily unavailable"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CollaborationMutationResultV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Mark my visible mentions read"
|
||||
tags:
|
||||
- "collaboration"
|
||||
servers:
|
||||
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
|
||||
url: "/"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
-- All room keys share a table hash tag. Redis TIME owns lease expiry.
|
||||
-- Validate every pair before changing either record or table membership.
|
||||
for i = 1, #KEYS, 2 do
|
||||
if redis.call('ZCARD', KEYS[i]) ~= redis.call('HLEN', KEYS[i + 1]) then
|
||||
return redis.error_reply('Presence room state is incomplete')
|
||||
end
|
||||
end
|
||||
local clock = redis.call('TIME')
|
||||
local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)
|
||||
local member = ARGV[1]
|
||||
local ttl = tonumber(ARGV[5])
|
||||
local capacity = tonumber(ARGV[6])
|
||||
local sequence = tonumber(ARGV[3])
|
||||
local departed = ARGV[4] == '1'
|
||||
|
||||
for i = 1, #KEYS, 2 do
|
||||
local expired = redis.call('ZRANGEBYSCORE', KEYS[i], '-inf', now)
|
||||
for _, id in ipairs(expired) do
|
||||
redis.call('ZREM', KEYS[i], id)
|
||||
redis.call('HDEL', KEYS[i + 1], id)
|
||||
end
|
||||
end
|
||||
|
||||
local previous = redis.call('HGET', KEYS[2], member)
|
||||
-- A record lease and its table projection are written atomically. If one is
|
||||
-- missing while the other is live, do not erase surviving ordering/tombstones.
|
||||
if #KEYS == 4 then
|
||||
local tablePrevious = redis.call('HGET', KEYS[4], member)
|
||||
if (previous and not tablePrevious) or (tablePrevious and not previous) then
|
||||
return redis.error_reply('Presence room state is incomplete')
|
||||
end
|
||||
end
|
||||
local old = previous and cjson.decode(previous) or nil
|
||||
local operation = ARGV[8] or 'legacy'
|
||||
-- Renewal authority is issued by open, not inferred from a caller's sequence.
|
||||
-- Missing/expired/departed leases cannot be recreated by delayed HTTP requests.
|
||||
if operation == 'renew' and (not old or not old.leased or old.departed) then
|
||||
return -1
|
||||
end
|
||||
if operation == 'open' and old then return -1 end
|
||||
if operation == 'legacy' and not departed and old and old.leased then return -1 end
|
||||
if old then
|
||||
local oldSequence = old.sequence
|
||||
if oldSequence == cjson.null then oldSequence = nil end
|
||||
if oldSequence then
|
||||
if not sequence or sequence < oldSequence or (sequence == oldSequence and (not departed or old.departed)) then
|
||||
return 1
|
||||
end
|
||||
elseif not sequence and old.at == now and old.departed and not departed then
|
||||
return 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Check every room before writing any membership, avoiding a partial join.
|
||||
for i = 1, #KEYS, 2 do
|
||||
if redis.call('HEXISTS', KEYS[i + 1], member) == 0 and redis.call('ZCARD', KEYS[i]) >= capacity then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
local data = cjson.decode(ARGV[2])
|
||||
data.at = now
|
||||
data.sequence = sequence or cjson.null
|
||||
data.departed = departed
|
||||
data.pending = operation == 'open'
|
||||
data.leased = operation ~= 'legacy' or (old and old.leased) or false
|
||||
for i = 1, #KEYS, 2 do
|
||||
-- Record scope comes first; table scope must never carry focused-field data.
|
||||
if i > 1 or ARGV[7] == '1' then data.fieldName = cjson.null end
|
||||
redis.call('HSET', KEYS[i + 1], member, cjson.encode(data))
|
||||
redis.call('ZADD', KEYS[i], now + ttl, member)
|
||||
redis.call('PEXPIRE', KEYS[i], ttl)
|
||||
redis.call('PEXPIRE', KEYS[i + 1], ttl)
|
||||
end
|
||||
return 1
|
||||
@@ -0,0 +1,39 @@
|
||||
-- A partially evicted room is unavailable, not empty. Do not silently discard it.
|
||||
for i = 1, #KEYS, 2 do
|
||||
if redis.call('ZCARD', KEYS[i]) ~= redis.call('HLEN', KEYS[i + 1]) then
|
||||
return redis.error_reply('Presence room state is incomplete')
|
||||
end
|
||||
end
|
||||
local clock = redis.call('TIME')
|
||||
local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)
|
||||
for i = 1, #KEYS, 2 do
|
||||
local expired = redis.call('ZRANGEBYSCORE', KEYS[i], '-inf', now)
|
||||
for _, id in ipairs(expired) do
|
||||
redis.call('ZREM', KEYS[i], id)
|
||||
redis.call('HDEL', KEYS[i + 1], id)
|
||||
end
|
||||
end
|
||||
local members = redis.call('ZRANGE', KEYS[1], 0, -1)
|
||||
-- Table membership is bounded by the configured capacity. Compare the record's
|
||||
-- projection without key discovery or cross-slot reads.
|
||||
if #KEYS == 4 then
|
||||
local projected = 0
|
||||
for _, id in ipairs(redis.call('ZRANGE', KEYS[3], 0, -1)) do
|
||||
if string.sub(id, -string.len(ARGV[1])) == ARGV[1] then
|
||||
projected = projected + 1
|
||||
if not redis.call('ZSCORE', KEYS[1], id) then
|
||||
return redis.error_reply('Presence room state is incomplete')
|
||||
end
|
||||
end
|
||||
end
|
||||
if projected ~= #members then
|
||||
return redis.error_reply('Presence room state is incomplete')
|
||||
end
|
||||
end
|
||||
local result = {}
|
||||
for _, id in ipairs(members) do
|
||||
local data = redis.call('HGET', KEYS[2], id)
|
||||
if not data then return redis.error_reply('Presence room state is incomplete') end
|
||||
table.insert(result, data)
|
||||
end
|
||||
return result
|
||||
@@ -60,7 +60,7 @@ class CollaborationApiContractTest
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration") || name.equals(BasicErrorResponseV1.class.getSimpleName()));
|
||||
assertThat(model.getPaths().keySet()).allMatch(pathName -> pathName.startsWith("/qqq/v1/qbit/collaboration/"));
|
||||
assertThat(generated).contains("multipart/form-data", "format: \"binary\"", "clients follow url", "nextCursor");
|
||||
assertThat(extension.getSpecs()).hasSize(45);
|
||||
assertThat(extension.getSpecs()).hasSize(48);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -63,6 +63,23 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
*******************************************************************************/
|
||||
class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
{
|
||||
/***************************************************************************
|
||||
** Shared presence has an explicit unavailable response, never a local fallback.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void unavailablePresenceHttp() throws Exception
|
||||
{
|
||||
config.withRedisPresence(new com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig()
|
||||
.withConnectionName("not-started").withDeployment("test").withApplication("fixture")
|
||||
.withScopeProvider(new QCodeReference(RedisPresenceServiceTest.TenantScope.class)));
|
||||
assertError(503, "GET", "/presence?table=document&pk=1", null, "alice");
|
||||
assertError(503, "POST", "/presence/leases", "{\"table\":\"document\",\"pk\":\"1\"}", "alice");
|
||||
assertError(503, "POST", "/presence/leases/renew", "{\"connectionId\":\"lease:missing\",\"table\":\"document\",\"pk\":\"1\",\"mode\":\"VIEWING\",\"sequence\":1}", "alice");
|
||||
assertError(400, "POST", "/presence", "{\"connectionId\":\"tab\",\"table\":\"document\",\"pk\":\"1\",\"mode\":\"VIEWING\"}", "alice");
|
||||
assertError(503, "DELETE", "/presence", "{\"connectionId\":\"tab\",\"table\":\"document\",\"pk\":\"1\"}", "alice");
|
||||
assertError(404, "GET", "/presence?table=document&pk=3", null, "alice");
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private final HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
private QApplicationJavalinServer server;
|
||||
@@ -231,6 +248,27 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
assertThat(ok("GET", "/presence?table=document&pk=1", null).get("viewers")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** HTTP-issued leases are invisible until activated and expire explicitly.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void issuedPresenceHttp() throws Exception
|
||||
{
|
||||
String lease = ok("POST", "/presence/leases", "{\"table\":\"document\",\"pk\":\"01\"}").get("connectionId").asText();
|
||||
assertThat(lease).startsWith("lease:");
|
||||
assertThat(ok("GET", "/presence?table=document&pk=1", null).get("viewers")).isEmpty();
|
||||
String body = new ObjectMapper().writeValueAsString(Map.of("connectionId", lease, "table", "document", "pk", "1", "mode", "EDITING", "fieldName", "title", "sequence", 1));
|
||||
assertThat(ok("POST", "/presence/leases/renew", body).get("viewers")).hasSize(1);
|
||||
assertError(404, "POST", "/presence/leases", "{\"table\":\"document\",\"pk\":\"3\"}", "alice");
|
||||
assertError(404, "POST", "/presence/leases/renew", body, "outsider");
|
||||
assertError(400, "POST", "/presence/leases/renew", body.replace("\"sequence\":1", "\"sequence\":0"), "alice");
|
||||
String leave = new ObjectMapper().writeValueAsString(Map.of("connectionId", lease, "table", "document", "pk", "1", "sequence", 2));
|
||||
ok("DELETE", "/presence", leave);
|
||||
var expired = json(request("POST", "/presence/leases/renew", body, "alice"), 409);
|
||||
assertThat(expired.get("code").asText()).isEqualTo("PRESENCE_LEASE_EXPIRED");
|
||||
assertThat(ok("GET", "/presence?table=document&pk=1", null).get("viewers")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every personal operation and offset cursor runs through the HTTP boundary.
|
||||
*******************************************************************************/
|
||||
@@ -402,6 +440,21 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=carol", null).at("/items/0");
|
||||
assertThat(candidate.at("/person/userId").asText()).isEqualTo("carol");
|
||||
assertThat(candidate.get("canRead").asText()).isEqualTo("true");
|
||||
json(request("POST", "/records/document/1/comments", "{\"body\":\"Review @[Alice](user:alice)\"}", "carol"), 200);
|
||||
ok("POST", "/records/document/1/comments", "{\"body\":\"Review @[Carol](user:carol)\"}");
|
||||
// Delivery is external to this HTTP fixture; persist its receipt before testing read state.
|
||||
for(var mention : CollaborationRecords.query(com.kingsrook.qbits.collaboration.model.CollaborationTables.MENTION,
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()))
|
||||
{
|
||||
CollaborationRecords.update(com.kingsrook.qbits.collaboration.model.CollaborationTables.MENTION,
|
||||
new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("id", mention.getValue("id")).withValue("notifiedAt", java.time.Instant.now()));
|
||||
}
|
||||
assertThat(ok("GET", "/my-work", null).get("unreadMentionCount").asLong()).isPositive();
|
||||
assertThat(json(request("GET", "/my-work", null, "carol"), 200).get("unreadMentionCount").asLong()).isEqualTo(1);
|
||||
ok("POST", "/my-work/mentions/read", null);
|
||||
assertThat(ok("GET", "/my-work", null).get("unreadMentionCount").asLong()).isZero();
|
||||
assertThat(json(request("GET", "/my-work", null, "carol"), 200).get("unreadMentionCount").asLong()).isEqualTo(1);
|
||||
ok("POST", "/my-work/mentions/read", null);
|
||||
assertThat(ok("GET", "/records/document/1/follow", null).at("/follow/reason").asText()).isEqualTo("COMMENTED");
|
||||
JsonNode state = ok("PUT", "/records/document/1/follow", "{\"notifyOnChange\":false,\"notifyOnComment\":true}");
|
||||
assertThat(state.at("/follow/notifyOnChange").asBoolean()).isFalse();
|
||||
@@ -509,7 +562,7 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
JsonNode version = json(client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/qqq/v1/version")).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString()), 200);
|
||||
assertThat(version.at("/extensions/0/name").asText()).isEqualTo("collaboration");
|
||||
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.1.0");
|
||||
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.3.0");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+2
-1
@@ -65,6 +65,7 @@ class CollaborationTablesGuardrailTest extends PersonalServiceFixture
|
||||
Map.entry("collaboration_favorite", "user content: a person stars something"),
|
||||
Map.entry("collaboration_recent", "runtime: a person opens a record"),
|
||||
Map.entry("collaboration_attachment", "user content: a person uploads a file"),
|
||||
Map.entry("collaboration_attachment_cleanup", "runtime: authorized deletion queues durable storage cleanup"),
|
||||
Map.entry("collaboration_comment", "user content: a person comments"),
|
||||
Map.entry("collaboration_comment_revision", "runtime: a comment is edited"),
|
||||
Map.entry("collaboration_mention", "runtime: a comment mentions someone"),
|
||||
@@ -183,7 +184,7 @@ class CollaborationTablesGuardrailTest extends PersonalServiceFixture
|
||||
private List<String> collaborationTables()
|
||||
{
|
||||
List<String> tables = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("collaboration_")).sorted().toList();
|
||||
assertThat(tables).hasSize(19);
|
||||
assertThat(tables).hasSize(20);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ class CollaborationTest
|
||||
CollaborationTables.SCOPE_TYPE, CollaborationTables.FEATURE, CollaborationTables.PERMISSION_MODE);
|
||||
assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12);
|
||||
assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName())
|
||||
.containsExactlyInAnyOrder(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);
|
||||
.containsExactlyInAnyOrder(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);
|
||||
assertThat(instance.getApp("collaboration").getWidgets()).containsExactlyElementsOf(CollaborationHome.HOME_WIDGETS);
|
||||
for(String widget : CollaborationHome.HOME_WIDGETS)
|
||||
{
|
||||
@@ -221,7 +221,7 @@ class CollaborationTest
|
||||
QInstance headless = new QInstance();
|
||||
headless.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
new CollaborationQBitProducer().withQBitConfig(config.withAdminApp(false)).produce(headless);
|
||||
assertThat(headless.getTables()).hasSize(19);
|
||||
assertThat(headless.getTables()).hasSize(20);
|
||||
assertThat(headless.getApps()).isEmpty();
|
||||
assertThat(headless.getWidgets()).hasSize(5);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.sql.DriverManager;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.mywork.MyWorkService;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.C3P0PooledConnectionProvider;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.ConnectionPoolSettings;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.wait.strategy.Wait;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Real PostgreSQL query counts for the bounded, caller-authorized My Work scan.
|
||||
*******************************************************************************/
|
||||
class MyWorkPostgresTest extends PersonalServiceFixture
|
||||
{
|
||||
private static GenericContainer<?> postgres;
|
||||
|
||||
/*******************************************************************************
|
||||
** Collect SQL counts in an owned disposable database, never an application DB.
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void startPostgres()
|
||||
{
|
||||
postgres = new GenericContainer<>("postgres:17-alpine")
|
||||
.withEnv("POSTGRES_PASSWORD", "mywork-fixture-only").withExposedPorts(5432)
|
||||
.withCommand("postgres", "-c", "shared_preload_libraries=pg_stat_statements")
|
||||
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*", 2));
|
||||
postgres.start();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop only the database owned by this class.
|
||||
*******************************************************************************/
|
||||
@AfterAll
|
||||
static void stopPostgres()
|
||||
{
|
||||
if(postgres != null)
|
||||
{
|
||||
postgres.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse the real authorization fixture with PostgreSQL persistence.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception
|
||||
{
|
||||
super.setUp();
|
||||
instance.getBackends().remove("memory");
|
||||
instance.addBackend(new PostgreSQLBackendMetaData().withName("memory").withHostName(postgres.getHost())
|
||||
.withPort(postgres.getMappedPort(5432)).withDatabaseName("postgres")
|
||||
.withUsername("postgres").withPassword("mywork-fixture-only"));
|
||||
var backend = (PostgreSQLBackendMetaData) instance.getBackend("memory");
|
||||
backend.setConnectionProvider(new QCodeReference(C3P0PooledConnectionProvider.class));
|
||||
backend.setConnectionPoolSettings(new ConnectionPoolSettings().withInitialPoolSize(1).withMinPoolSize(1)
|
||||
.withMaxPoolSize(3).withAcquireIncrement(1).withCheckoutTimeoutSeconds(2));
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("CREATE EXTENSION pg_stat_statements");
|
||||
sql.execute(CollaborationSchema.postgresScript(config));
|
||||
sql.execute("CREATE TABLE document (id INTEGER PRIMARY KEY, title TEXT, tenant TEXT)");
|
||||
sql.execute("INSERT INTO document VALUES (1, 'Visible', 'a'), (3, 'Private', 'b')");
|
||||
sql.execute("INSERT INTO collaboration_task (id, table_name, record_id, title, assignee_user_id, created_by_user_id, status, parent_deleted, due_at) "
|
||||
+ "SELECT n, 'document', CASE WHEN n=601 THEN '3' ELSE '1' END, 'Task ' || n, 'alice', 'alice', 'OPEN', false, "
|
||||
+ "CASE WHEN n%2=0 THEN TIMESTAMPTZ '2026-10-22 00:00:00Z' + (601-n)*INTERVAL '1 second' ELSE NULL END FROM generate_series(1,601) n");
|
||||
sql.execute("SELECT pg_stat_statements_reset()");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Release owner pools before container teardown.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void tearDown()
|
||||
{
|
||||
ConnectionManager.closeConnectionProviders(instance);
|
||||
super.tearDown();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact counts/order and bounded SQL parent/page queries on the real backend.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void summaryPreservesVisibilityWithBoundedSqlQueries() throws Exception
|
||||
{
|
||||
var summary = new MyWorkService().summary();
|
||||
assertThat(summary.openTaskCount()).isEqualTo(600);
|
||||
assertThat(summary.tasks()).extracting(row -> row.getValueInteger("id"))
|
||||
.containsExactly(600, 598, 596, 594, 592, 590, 588, 586, 584, 582);
|
||||
try(Connection connection = connect(); var sql = connection.createStatement();
|
||||
var rows = sql.executeQuery("SELECT query, calls FROM pg_stat_statements WHERE query ILIKE 'SELECT%'") )
|
||||
{
|
||||
long parents = 0;
|
||||
long tasks = 0;
|
||||
while(rows.next())
|
||||
{
|
||||
String query = rows.getString(1);
|
||||
if(query.contains("\"document\""))
|
||||
{
|
||||
parents += rows.getLong(2);
|
||||
}
|
||||
if(query.contains("\"collaboration_task\""))
|
||||
{
|
||||
tasks += rows.getLong(2);
|
||||
}
|
||||
}
|
||||
assertThat(parents).as("SQL parent queries").isBetween(1L, 8L);
|
||||
assertThat(tasks).as("SQL task page queries").isEqualTo(5);
|
||||
}
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("b")));
|
||||
assertThat(new MyWorkService().summary().openTaskCount()).isEqualTo(1);
|
||||
if(Boolean.getBoolean("qqq.mywork.load"))
|
||||
{
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("a")));
|
||||
measureLoad();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt-in reproducible SQL work measurement, not a deployment capacity claim.
|
||||
*******************************************************************************/
|
||||
private void measureLoad() throws Exception
|
||||
{
|
||||
final int tasks = 2000;
|
||||
final int samples = 10;
|
||||
List<String> report = new ArrayList<>();
|
||||
report.add("tasks,parents,samples,p50_ms,p95_ms,p99_ms,sql_calls,sql_execution_ms");
|
||||
for(int parents : List.of(10, tasks))
|
||||
{
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("TRUNCATE collaboration_task, document");
|
||||
sql.execute("INSERT INTO document SELECT n, 'Document ' || n, CASE WHEN n%10=0 THEN 'b' ELSE 'a' END FROM generate_series(1," + parents + ") n");
|
||||
sql.execute("INSERT INTO collaboration_task (id, table_name, record_id, title, assignee_user_id, created_by_user_id, status, parent_deleted) "
|
||||
+ "SELECT n, 'document', (((n-1)%" + parents + ")+1)::text, 'Task ' || n, 'alice', 'alice', 'OPEN', false FROM generate_series(1," + tasks + ") n");
|
||||
}
|
||||
// Warm the JVM and pool without counting these calls in the samples.
|
||||
for(int warmup = 0; warmup < 2; warmup++)
|
||||
{
|
||||
assertThat(new MyWorkService().summary().openTaskCount()).isEqualTo(1800);
|
||||
}
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("SELECT pg_stat_statements_reset()");
|
||||
}
|
||||
double[] elapsed = new double[samples];
|
||||
for(int sample = 0; sample < samples; sample++)
|
||||
{
|
||||
long start = System.nanoTime();
|
||||
var summary = new MyWorkService().summary();
|
||||
elapsed[sample] = (System.nanoTime() - start) / 1_000_000.0;
|
||||
assertThat(summary.openTaskCount()).isEqualTo(1800);
|
||||
assertThat(summary.tasks()).extracting(row -> row.getValueInteger("id"))
|
||||
.containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 11);
|
||||
}
|
||||
var backend = (PostgreSQLBackendMetaData) instance.getBackend("memory");
|
||||
var pool = ConnectionManager.getPoolMetrics(instance, backend);
|
||||
// C3P0 returns connections asynchronously; allow a bounded check-in interval.
|
||||
long deadline = System.nanoTime() + 2_000_000_000L;
|
||||
while(pool.get("connectionsBusy").intValue() != 0 && System.nanoTime() < deadline)
|
||||
{
|
||||
Thread.sleep(10);
|
||||
pool = ConnectionManager.getPoolMetrics(instance, backend);
|
||||
}
|
||||
assertThat(pool.get("connectionsTotal").intValue()).isBetween(1, 3);
|
||||
assertThat(pool.get("connectionsBusy").intValue()).isZero();
|
||||
assertThat(pool.get("checkoutTimeouts").longValue()).isZero();
|
||||
Arrays.sort(elapsed);
|
||||
try(Connection connection = connect(); var sql = connection.createStatement();
|
||||
var rows = sql.executeQuery("SELECT COALESCE(sum(calls),0), COALESCE(sum(total_exec_time),0) FROM pg_stat_statements "
|
||||
+ "WHERE query ILIKE 'SELECT%' AND (query LIKE '%\"document\"%' OR query LIKE '%\"collaboration_task\"%' "
|
||||
+ "OR query LIKE '%\"collaboration_record_assignment\"%' OR query LIKE '%\"collaboration_mention\"%')"))
|
||||
{
|
||||
assertThat(rows.next()).isTrue();
|
||||
report.add(String.format(Locale.ROOT, "%d,%d,%d,%.3f,%.3f,%.3f,%d,%.3f", tasks, parents, samples,
|
||||
elapsed[4], elapsed[9], elapsed[9], rows.getLong(1), rows.getDouble(2)));
|
||||
}
|
||||
}
|
||||
Path output = Path.of("target/mywork-load.csv");
|
||||
Files.createDirectories(output.getParent());
|
||||
Files.write(output, report);
|
||||
System.out.println("My Work local PostgreSQL workload: " + output.toAbsolutePath());
|
||||
report.forEach(System.out::println);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A fixture-owned connection for setup and independent SQL observations.
|
||||
*******************************************************************************/
|
||||
private Connection connect() throws Exception
|
||||
{
|
||||
return (DriverManager.getConnection("jdbc:postgresql://" + postgres.getHost() + ":" + postgres.getMappedPort(5432) + "/postgres", "postgres", "mywork-fixture-only"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.mywork.MyWorkService;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
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.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Measure backend queries while preserving exact, caller-visible summary counts.
|
||||
*******************************************************************************/
|
||||
class MyWorkQueryBudgetTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Counts cross page boundaries without including another user's or hidden work.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void summaryPagesAssignmentsAndMentionsWithoutLeakingCounts() throws Exception
|
||||
{
|
||||
List<QRecord> parents = new ArrayList<>();
|
||||
List<QRecord> assignments = new ArrayList<>();
|
||||
List<QRecord> mentions = new ArrayList<>();
|
||||
for(int id = 1; id <= 208; id++)
|
||||
{
|
||||
if(id > 3)
|
||||
{
|
||||
parents.add(new QRecord().withValue("id", id).withValue("title", "Parent " + id).withValue("tenant", id == 208 ? "b" : "a"));
|
||||
}
|
||||
assignments.add(new QRecord().withValue("id", id).withValue("tableName", "document").withValue("recordId", Integer.toString(id))
|
||||
.withValue("assigneeUserId", id == 207 ? "bob" : "alice").withValue("parentDeleted", false));
|
||||
}
|
||||
for(int id = 1; id <= 601; id++)
|
||||
{
|
||||
mentions.add(new QRecord().withValue("id", id).withValue("tableName", "document").withValue("recordId", Integer.toString(id % 3 + 1))
|
||||
.withValue("mentionedUserId", "alice").withValue("notifiedAt", Instant.now()).withValue("parentDeleted", false));
|
||||
}
|
||||
mentions.add(new QRecord().withValue("id", 602).withValue("tableName", "document").withValue("recordId", "1")
|
||||
.withValue("mentionedUserId", "bob").withValue("notifiedAt", Instant.now()).withValue("parentDeleted", false));
|
||||
mentions.add(new QRecord().withValue("id", 603).withValue("tableName", "document").withValue("recordId", "1")
|
||||
.withValue("mentionedUserId", "alice").withValue("parentDeleted", false));
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput("document").withRecords(parents), true);
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.RECORD_ASSIGNMENT).withRecords(assignments), true);
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.MENTION).withRecords(mentions), true);
|
||||
MemoryRecordStore.resetStatistics();
|
||||
MemoryRecordStore.setCollectStatistics(true);
|
||||
try
|
||||
{
|
||||
var summary = new MyWorkService().summary();
|
||||
assertThat(summary.assignedRecordsByTable()).containsOnlyKeys("document").containsEntry("document", 205L);
|
||||
assertThat(summary.unreadMentionCount()).isEqualTo(401);
|
||||
assertThat(summary.openTaskCount()).isZero();
|
||||
assertThat(summary.tasks()).isEmpty();
|
||||
MemoryRecordStore.getActionInputs().get(QueryInput.class).stream().map(input -> (QueryInput) input)
|
||||
.filter(input -> input.getTableName().startsWith("collaboration_"))
|
||||
.forEach(input -> assertThat(input.getFilter().getLimit()).isNotNull().isLessThanOrEqualTo(200));
|
||||
}
|
||||
finally
|
||||
{
|
||||
MemoryRecordStore.setCollectStatistics(false);
|
||||
MemoryRecordStore.resetStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Hundreds of tasks on one parent must not cause hundreds of parent lookups.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void summaryBoundsPagesAndRepeatedParentLookups() throws Exception
|
||||
{
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
for(int id = 1; id <= 601; id++)
|
||||
{
|
||||
rows.add(new QRecord().withValue("id", id).withValue("tableName", "document")
|
||||
.withValue("recordId", id == 601 ? "3" : "1").withValue("title", "Task " + id)
|
||||
.withValue("assigneeUserId", "alice").withValue("createdByUserId", "alice")
|
||||
.withValue("status", "OPEN").withValue("parentDeleted", false)
|
||||
.withValue("dueAt", id % 2 == 0 ? Instant.parse("2026-10-22T00:00:00Z").plusSeconds(601 - id) : null));
|
||||
}
|
||||
// Populate historical rows directly so current parent authorization is tested on read.
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.TASK).withRecords(rows), true);
|
||||
MemoryRecordStore.resetStatistics();
|
||||
MemoryRecordStore.setCollectStatistics(true);
|
||||
try
|
||||
{
|
||||
var summary = new MyWorkService().summary();
|
||||
assertThat(summary.openTaskCount()).isEqualTo(600);
|
||||
assertThat(summary.tasks()).extracting(row -> row.getValueInteger("id"))
|
||||
.containsExactly(600, 598, 596, 594, 592, 590, 588, 586, 584, 582);
|
||||
List<QueryInput> queries = MemoryRecordStore.getActionInputs().get(QueryInput.class).stream()
|
||||
.map(input -> (QueryInput) input).toList();
|
||||
assertThat(queries.stream().filter(input -> "document".equals(input.getTableName())).count()).isLessThanOrEqualTo(8);
|
||||
assertThat(queries.stream().filter(input -> CollaborationTables.TASK.equals(input.getTableName())))
|
||||
.allSatisfy(input -> assertThat(input.getFilter().getLimit()).isNotNull().isLessThanOrEqualTo(200));
|
||||
// A second request must recheck current permissions rather than reuse cached decisions.
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("b")));
|
||||
assertThat(new MyWorkService().summary().openTaskCount()).isEqualTo(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MemoryRecordStore.setCollectStatistics(false);
|
||||
MemoryRecordStore.resetStatistics();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise a consumer classpath without the optional Redis implementation/driver.
|
||||
*******************************************************************************/
|
||||
class OptionalRedisConsumerTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** An isolated loader must not resolve Redis from the test runner's parent.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void memoryPresenceWithoutRedis() throws Exception
|
||||
{
|
||||
String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path"));
|
||||
URL[] urls = Arrays.stream(classpath.split(java.io.File.pathSeparator))
|
||||
.filter(entry -> !entry.replace('\\', '/').contains("/qbit-redis/")
|
||||
&& !entry.replace('\\', '/').contains("/qbit-email/")
|
||||
&& !Path.of(entry).getFileName().toString().startsWith("jedis-"))
|
||||
.map(entry ->
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Path.of(entry).toUri().toURL());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException(e));
|
||||
}
|
||||
}).toArray(URL[]::new);
|
||||
ClassLoader previous = Thread.currentThread().getContextClassLoader();
|
||||
try(var loader = new URLClassLoader(urls, ClassLoader.getPlatformClassLoader()))
|
||||
{
|
||||
assertThrows(ClassNotFoundException.class, () -> loader.loadClass("com.kingsrook.qbits.redis.api.RedisNamespace"));
|
||||
assertThrows(ClassNotFoundException.class, () -> loader.loadClass("redis.clients.jedis.RedisClient"));
|
||||
assertThrows(ClassNotFoundException.class, () -> loader.loadClass("com.kingsrook.qbits.email.api.EmailApi"));
|
||||
Thread.currentThread().setContextClassLoader(loader);
|
||||
loader.loadClass(Probe.class.getName()).getMethod("exercise").invoke(null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.currentThread().setContextClassLoader(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Loaded in the isolated consumer, including metadata enrichment and delivery.
|
||||
***************************************************************************/
|
||||
public static class Probe extends PersonalServiceFixture
|
||||
{
|
||||
/************************************************************************
|
||||
** Real host setup and authorized presence work without Redis artifacts.
|
||||
************************************************************************/
|
||||
public static void exercise() throws Exception
|
||||
{
|
||||
var probe = new Probe();
|
||||
try
|
||||
{
|
||||
probe.setUp();
|
||||
var service = new com.kingsrook.qbits.collaboration.presence.PresenceService();
|
||||
String lease = service.openLease("document", "1");
|
||||
assertTrue(service.snapshot("document", "1").isEmpty());
|
||||
assertEquals(1, service.renewLease(lease, "document", "1", "VIEWING", null, 1L).size());
|
||||
service.leave(lease, "document", "1", 2L);
|
||||
assertTrue(service.snapshot("document", "1").isEmpty());
|
||||
assertFalse(new com.kingsrook.qbits.collaboration.presence.RedisPresenceAudience().allows("untrusted"));
|
||||
probe.config.withUserSessionProvider(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(RecipientSessions.class));
|
||||
assertEquals(java.util.Optional.of(true), new com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker()
|
||||
.canRead("alice", "document", "1"));
|
||||
var notice = new com.kingsrook.qbits.collaboration.notify.CollaborationNotification("alice", "Test", "Test",
|
||||
com.kingsrook.qbits.collaboration.notify.CollaborationNotification.Category.MENTION, "document", "1", "/app/document/1");
|
||||
var receipts = new java.util.concurrent.atomic.AtomicInteger();
|
||||
com.kingsrook.qbits.collaboration.notify.Notifications.send(notice, () -> receipts.incrementAndGet());
|
||||
assertEquals(0, receipts.get(), "Missing email must not acknowledge delivery");
|
||||
probe.config.withNotifier(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(AcceptingNotifier.class));
|
||||
com.kingsrook.qbits.collaboration.notify.Notifications.send(notice, () -> receipts.incrementAndGet());
|
||||
assertEquals(1, receipts.get(), "Explicit host notifier must still work without email");
|
||||
}
|
||||
finally
|
||||
{
|
||||
probe.tearDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Host session lookup makes the notification eligible for actual delivery.
|
||||
***************************************************************************/
|
||||
public static class RecipientSessions implements com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface
|
||||
{
|
||||
/************************************************************************
|
||||
** Fixture recipient has access to the parent record in tenant a.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public java.util.Optional<com.kingsrook.qqq.backend.core.model.session.QSession> sessionForUser(String userId)
|
||||
{
|
||||
return (java.util.Optional.of(new com.kingsrook.qqq.backend.core.model.session.QSession()
|
||||
.withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference(userId))
|
||||
.withPermissions("document.read").withSecurityKeyValue("tenant", "a")));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A host transport that accepts delivery without the optional email adapter.
|
||||
***************************************************************************/
|
||||
public static class AcceptingNotifier implements com.kingsrook.qbits.collaboration.notify.CollaborationNotifier
|
||||
{
|
||||
/************************************************************************
|
||||
** Test transport has no external delivery side effects.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public void notify(com.kingsrook.qbits.collaboration.notify.CollaborationNotification notification)
|
||||
{
|
||||
assertEquals("alice", notification.recipientUserId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,163 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
*******************************************************************************/
|
||||
class PresenceTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Issued service operations retain identity, authorization and cleanup rules.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void issuedServiceRechecksAccessAndDepartureOrder() throws Exception
|
||||
{
|
||||
var service = new PresenceService();
|
||||
String lease = service.openLease("document", "01");
|
||||
assertThat(service.snapshot("document", "1")).isEmpty();
|
||||
assertThatThrownBy(() -> service.renewLease(lease, "document", "1", "EDITING", "missing", 1L))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QBadRequestException.class);
|
||||
assertThat(service.renewLease(lease, "document", "1", "EDITING", "title", 1L)).hasSize(1);
|
||||
assertThatThrownBy(() -> service.leave(lease, "document", "1", null))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QBadRequestException.class);
|
||||
login("bob");
|
||||
assertThatThrownBy(() -> service.renewLease(lease, "document", "1", "EDITING", "title", 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
login("alice");
|
||||
QContext.getQSession().getPermissions().remove("document.read");
|
||||
assertThatThrownBy(() -> service.openLease("document", "1")).isInstanceOf(QNotFoundException.class);
|
||||
assertThatThrownBy(() -> service.renewLease(lease, "document", "1", "EDITING", "title", 3L))
|
||||
.isInstanceOf(QNotFoundException.class);
|
||||
assertThat(PresenceRegistry.get(instance).snapshot("document", "1")).isEmpty();
|
||||
login("alice");
|
||||
assertThatThrownBy(() -> service.renewLease(lease, "document", "1", "EDITING", "title", 4L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Local issued authority is bounded and cannot be recreated by late requests.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void localIssuedLeasesRequireExistingAuthority() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var local = new PresenceRegistry(bus, clock, 1); var other = new PresenceRegistry(bus, clock, 1))
|
||||
{
|
||||
String lease = local.openLease("alice", "Alice", "document", "1");
|
||||
assertThat(lease).startsWith("lease:");
|
||||
assertThat(local.snapshot("document", "1")).isEmpty();
|
||||
assertThat(local.openLease("bob", "Bob", "document", "2")).isNull();
|
||||
assertThatThrownBy(() -> other.renewLease("alice", "Alice", lease, "document", "1", "EDITING", "title", 1L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThatThrownBy(() -> local.renewLease("bob", "Bob", lease, "document", "1", "EDITING", "title", 1L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
local.renewLease("alice", "Alice", lease, "document", "1", "EDITING", "title", 1L);
|
||||
clock.advance(1000);
|
||||
local.renewLease("alice", "Alice", lease, "document", "1", "VIEWING", null, 1L);
|
||||
assertThat(local.snapshot("document", "1").getFirst().mode()).isEqualTo("EDITING");
|
||||
clock.advance(PresenceRegistry.TTL_MILLIS - 1000);
|
||||
assertThatThrownBy(() -> local.renewLease("alice", "Alice", lease, "document", "1", "EDITING", "title", 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(local.snapshot("document", "1")).isEmpty();
|
||||
String replacement = local.openLease("alice", "Alice", "document", "1");
|
||||
local.renewLease("alice", "Alice", replacement, "document", "1", "EDITING", "title", 1L);
|
||||
local.leave("alice", replacement, "document", "1", 3L);
|
||||
assertThatThrownBy(() -> local.renewLease("alice", "Alice", replacement, "document", "1", "EDITING", "title", 4L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
clock.advance(PresenceRegistry.TTL_MILLIS);
|
||||
assertThatThrownBy(() -> local.heartbeat("alice", "Alice", replacement, "document", "1", "EDITING", "title", 5L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThatThrownBy(() -> local.renewLease("alice", "Alice", replacement, "document", "1", "EDITING", "title", 5L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(local.snapshot("document", "1")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delayed HTTP renewals and departures retain client order across replicas.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void clientSequenceOrdersDelayedRequests() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, 10); var second = new PresenceRegistry(bus, clock, 10))
|
||||
{
|
||||
first.heartbeat("alice", "Alice", "tab", "document", "1", "EDITING", "title", 2L);
|
||||
clock.advance(10);
|
||||
second.heartbeat("alice", "Alice", "tab", "document", "1", "EDITING", "old", 1L);
|
||||
assertThat(first.snapshot("document", "1").getFirst().fieldName()).isEqualTo("title");
|
||||
second.leave("alice", "tab", "document", "1", 3L);
|
||||
clock.advance(10);
|
||||
first.heartbeat("alice", "Alice", "tab", "document", "1", "EDITING", "title", 2L);
|
||||
assertThat(first.snapshot("document", "1")).isEmpty();
|
||||
assertThat(second.snapshot("document", "1")).isEmpty();
|
||||
first.heartbeat("alice", "Alice", "tab", "document", "1", "VIEWING", null, 4L);
|
||||
second.leave("alice", "tab", "document", "1", 3L);
|
||||
assertThat(second.snapshot("document", "1")).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Departure keeps unknown-target errors without preventing revoked cleanup.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void departureRejectsUnknownTable() throws Exception
|
||||
{
|
||||
var service = new PresenceService();
|
||||
assertThatThrownBy(() -> service.leave("valid-connection", "nonexistent", null))
|
||||
.isInstanceOf(QNotFoundException.class).hasMessage("Not found");
|
||||
service.heartbeat("valid-connection", "document", "1", "VIEWING", null);
|
||||
QContext.getQSession().getPermissions().remove("document.read");
|
||||
service.leave("valid-connection", "document", "1");
|
||||
assertThat(PresenceRegistry.get(instance).snapshot("document", "1")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A departure wins over delayed and same-timestamp renewals on every pod.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void departedLeaseCannotBeResurrectedByOldHeartbeat() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var registry = new PresenceRegistry(bus, clock, 2))
|
||||
{
|
||||
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
|
||||
bus.subscribe(events::add);
|
||||
registry.heartbeat("alice", "Alice", "one", "document", "1", "EDITING", "title");
|
||||
var old = events.stream().filter(event -> event.getEvent().equals("presence.heartbeat")).findFirst().orElseThrow();
|
||||
registry.leave("alice", "one", "document", "1");
|
||||
bus.publish(old);
|
||||
assertThat(registry.snapshot("document", "1")).isEmpty();
|
||||
clock.advance(1);
|
||||
registry.heartbeat("alice", "Alice", "one", "document", "1", "VIEWING", null);
|
||||
assertThat(registry.snapshot("document", "1")).hasSize(1);
|
||||
clock.advance(PresenceRegistry.TTL_MILLIS);
|
||||
assertThat(registry.snapshot("document", "1")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Focus is validated for the sender and projected again for each recipient.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void focusedFieldsRespectVisibilityOnEveryResponse() throws Exception
|
||||
{
|
||||
var table = instance.getTable("document");
|
||||
table.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("hidden", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING).withIsHidden(true));
|
||||
table.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("password", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.PASSWORD));
|
||||
var service = new PresenceService();
|
||||
for(String field : List.of("hidden", "password", "missing"))
|
||||
{
|
||||
assertThatThrownBy(() -> service.heartbeat("alice-tab", "document", "1", "EDITING", field))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QBadRequestException.class);
|
||||
}
|
||||
service.heartbeat("alice-tab", "document", "1", "EDITING", "title");
|
||||
login("bob");
|
||||
assertThat(service.snapshot("document", "1").getFirst().fieldName()).isEqualTo("title");
|
||||
table.getField("title").setIsHidden(true);
|
||||
assertThat(service.snapshot("document", "1").getFirst().fieldName()).isNull();
|
||||
assertThat(service.heartbeat("bob-tab", "document", "1", "VIEWING", null))
|
||||
.allSatisfy(viewer -> assertThat(viewer.fieldName()).isNull());
|
||||
assertThat(PresenceRegistry.get(instance).snapshot("document", "1")).anySatisfy(viewer -> assertThat(viewer.fieldName()).isEqualTo("title"));
|
||||
table.getField("title").setIsHidden(false);
|
||||
assertThat(service.snapshot("document", "1")).anySatisfy(viewer -> assertThat(viewer.fieldName()).isEqualTo("title"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Renewals replicate even without a visible change; tabs merge until all expire.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceScopeProvider;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig;
|
||||
import com.kingsrook.qbits.redis.RedisConnectionConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitProducer;
|
||||
import com.kingsrook.qbits.redis.RedisRuntimeService;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QServiceUnavailableException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the authenticated service boundary against a real Redis runtime.
|
||||
*******************************************************************************/
|
||||
class RedisPresenceServiceTest extends PersonalServiceFixture
|
||||
{
|
||||
/***************************************************************************
|
||||
** Separate application runtimes share leases and deliver hints through Redis.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void twoNodesShareSixViewersAndTwoEditors() throws Exception
|
||||
{
|
||||
var second = new PersonalServiceFixture() { };
|
||||
second.setUp();
|
||||
var firstRuntime = new RedisRuntimeService();
|
||||
var secondRuntime = new RedisRuntimeService();
|
||||
var firstTransport = new com.kingsrook.qbits.redis.RedisRealtimeRuntimeService();
|
||||
var secondTransport = new com.kingsrook.qbits.redis.RedisRealtimeRuntimeService();
|
||||
try(var redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
var nodes = java.util.List.of(instance, second.instance);
|
||||
var configs = java.util.List.of(config, second.config);
|
||||
for(int i = 0; i < nodes.size(); i++)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.realtime.RealtimeBus.close(nodes.get(i));
|
||||
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("presence",
|
||||
new RedisConnectionConfig().withHost(redis.getHost()).withPort(redis.getMappedPort(6379)).withTls(false))
|
||||
.withRealtime("presence", "test", "multi-node")).produce(nodes.get(i));
|
||||
configs.get(i).withRedisPresence(new RedisPresenceConfig().withConnectionName("presence").withDeployment("test")
|
||||
.withApplication("multi-node").withScopeProvider(new QCodeReference(TenantScope.class)));
|
||||
}
|
||||
firstRuntime.start(instance);
|
||||
secondRuntime.start(second.instance);
|
||||
firstTransport.start(instance);
|
||||
secondTransport.start(second.instance);
|
||||
org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(5), () ->
|
||||
{
|
||||
for(var node : nodes)
|
||||
{
|
||||
while(!((com.kingsrook.qbits.redis.RedisRealtimeBus) com.kingsrook.qqq.backend.core.realtime.RealtimeBus.getIfStarted(node).orElseThrow()).isListening())
|
||||
{
|
||||
Thread.sleep(10);
|
||||
}
|
||||
}
|
||||
});
|
||||
try(var hub = new com.kingsrook.qqq.middleware.javalin.realtime.RealtimeHub(second.instance))
|
||||
{
|
||||
var observer = session("observer");
|
||||
QContext.init(second.instance, observer);
|
||||
var stream = hub.connect(observer, null);
|
||||
hub.subscriptions(observer, stream.getConnectionId(), java.util.List.of("record:document:1"), java.util.List.of());
|
||||
var otherTenant = session("other-tenant");
|
||||
otherTenant.clearSecurityKeyValues();
|
||||
otherTenant.withSecurityKeyValue("tenant", "b");
|
||||
QContext.init(second.instance, otherTenant);
|
||||
var otherStream = hub.connect(otherTenant, null);
|
||||
hub.subscriptions(otherTenant, otherStream.getConnectionId(), java.util.List.of("table:document"), java.util.List.of());
|
||||
var service = new PresenceService();
|
||||
var leases = new java.util.ArrayList<String>();
|
||||
for(int i = 0; i < 6; i++)
|
||||
{
|
||||
QContext.init(nodes.get((i + 1) % 2), session("user" + i));
|
||||
String lease = service.openLease("document", "1");
|
||||
leases.add(lease);
|
||||
assertThat(service.snapshot("document", "1")).hasSize(i);
|
||||
QContext.init(nodes.get(i % 2), session("user" + i));
|
||||
service.renewLease(lease, "document", "1", i < 2 ? "EDITING" : "VIEWING", i < 2 ? "title" : null, 1L);
|
||||
if(i == 0)
|
||||
{
|
||||
// Node one activates a lease opened on node two; its hint crosses nodes.
|
||||
org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(5), () ->
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
var frame = stream.poll(200);
|
||||
if(frame != null && "presence.invalidated".equals(frame.event()))
|
||||
{
|
||||
assertThat(frame.data()).containsOnlyKeys("table", "pk").containsEntry("pk", "1");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for(var node : nodes)
|
||||
{
|
||||
QContext.init(node, observer);
|
||||
var viewers = service.snapshot("document", "1");
|
||||
assertThat(viewers).hasSize(6);
|
||||
assertThat(viewers.stream().filter(viewer -> "EDITING".equals(viewer.mode())).count()).isEqualTo(2);
|
||||
assertThat(service.snapshot("document", null)).hasSize(6);
|
||||
}
|
||||
// A later unscoped marker proves the other tenant's stream processed
|
||||
// the earlier hints, instead of passing on an arbitrary short timeout.
|
||||
com.kingsrook.qqq.backend.core.realtime.RealtimeBus.getIfStarted(instance).orElseThrow().publish(
|
||||
new com.kingsrook.qqq.backend.core.realtime.RealtimeMessage().withChannel("table:document")
|
||||
.withEvent("test.barrier").withData(java.util.Map.of()));
|
||||
org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(5), () ->
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
var frame = otherStream.poll(200);
|
||||
if(frame != null)
|
||||
{
|
||||
assertThat(frame.event()).isNotEqualTo("presence.invalidated");
|
||||
if("test.barrier".equals(frame.event()))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
for(var node : nodes)
|
||||
{
|
||||
QContext.init(node, otherTenant);
|
||||
assertThat(service.snapshot("document", null)).isEmpty();
|
||||
assertThat(service.snapshot("document", "3")).isEmpty();
|
||||
assertThatThrownBy(() -> service.snapshot("document", "1")).isInstanceOf(QNotFoundException.class);
|
||||
}
|
||||
QContext.init(second.instance, session("user0"));
|
||||
service.leave(leases.getFirst(), "document", "1", 2L);
|
||||
QContext.init(instance, observer);
|
||||
assertThat(service.snapshot("document", "1")).hasSize(5);
|
||||
var firstBus = (com.kingsrook.qbits.redis.RedisRealtimeBus) com.kingsrook.qqq.backend.core.realtime.RealtimeBus.getIfStarted(instance).orElseThrow();
|
||||
var secondBus = (com.kingsrook.qbits.redis.RedisRealtimeBus) com.kingsrook.qqq.backend.core.realtime.RealtimeBus.getIfStarted(second.instance).orElseThrow();
|
||||
firstBus.publish(new com.kingsrook.qqq.backend.core.realtime.RealtimeMessage().withChannel("record:document:1")
|
||||
.withEvent("test.drained").withData(java.util.Map.of()));
|
||||
awaitEvent(stream, "test.drained");
|
||||
long reconnects = secondBus.metrics().reconnects();
|
||||
assertThat(redis.execInContainer("redis-cli", "CLIENT", "KILL", "TYPE", "pubsub").getExitCode()).isZero();
|
||||
assertThat(awaitEvent(stream, "resync").data()).isEmpty();
|
||||
org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(5), () ->
|
||||
{
|
||||
while(!firstBus.isListening() || !secondBus.isListening() || secondBus.metrics().reconnects() <= reconnects)
|
||||
{
|
||||
Thread.sleep(10);
|
||||
}
|
||||
});
|
||||
// The subscription failure does not erase authoritative presence leases.
|
||||
QContext.init(second.instance, observer);
|
||||
assertThat(service.snapshot("document", "1")).hasSize(5);
|
||||
QContext.init(instance, session("user0"));
|
||||
assertThatThrownBy(() -> service.renewLease(leases.getFirst(), "document", "1", "EDITING", "title", 3L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
String replacement = service.openLease("document", "1");
|
||||
service.renewLease(replacement, "document", "1", "EDITING", "title", 1L);
|
||||
assertThat(awaitEvent(stream, "presence.invalidated").data()).containsEntry("pk", "1");
|
||||
QContext.init(second.instance, observer);
|
||||
assertThat(service.snapshot("document", "1")).hasSize(6);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
firstTransport.stop();
|
||||
secondTransport.stop();
|
||||
firstRuntime.afterApplicationStop();
|
||||
secondRuntime.afterApplicationStop();
|
||||
second.tearDown();
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Wait for a stream event with a bounded deadline, consuming older frames.
|
||||
***************************************************************************/
|
||||
private static com.kingsrook.qqq.middleware.javalin.realtime.RealtimeHub.Frame awaitEvent(
|
||||
com.kingsrook.qqq.middleware.javalin.realtime.RealtimeHub.Attachment stream, String event)
|
||||
{
|
||||
return (org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(5), () ->
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
var frame = stream.poll(200);
|
||||
if(frame != null && event.equals(frame.event()))
|
||||
{
|
||||
return (frame);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Each simulated browser has an independent authenticated host session.
|
||||
***************************************************************************/
|
||||
private static com.kingsrook.qqq.backend.core.model.session.QSession session(String user)
|
||||
{
|
||||
return (new com.kingsrook.qqq.backend.core.model.session.QSession()
|
||||
.withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference(user))
|
||||
.withPermissions("document.read", "document.write").withSecurityKeyValue("tenant", "a"));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Redis preserves permission projection and fails explicitly during outages.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void permissionsIsolationAndUnavailable() throws Exception
|
||||
{
|
||||
try(var redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("presence",
|
||||
new RedisConnectionConfig().withHost(redis.getHost()).withPort(redis.getMappedPort(6379)).withTls(false)
|
||||
.withConnectionTimeoutMillis(200).withSocketTimeoutMillis(200))).produce(instance);
|
||||
config.withRedisPresence(new RedisPresenceConfig().withConnectionName("presence").withDeployment("test")
|
||||
.withApplication("fixture").withScopeProvider(new QCodeReference(TenantScope.class)));
|
||||
var runtime = new RedisRuntimeService();
|
||||
try
|
||||
{
|
||||
runtime.start(instance);
|
||||
var service = new PresenceService();
|
||||
String aliceLease;
|
||||
try(var hub = new com.kingsrook.qqq.middleware.javalin.realtime.RealtimeHub(instance))
|
||||
{
|
||||
var viewer = hub.connect(QContext.getQSession(), null);
|
||||
viewer.poll(100);
|
||||
hub.subscriptions(QContext.getQSession(), viewer.getConnectionId(), java.util.List.of("table:document", "record:document:1"), java.util.List.of());
|
||||
login("other-tenant");
|
||||
QContext.getQSession().clearSecurityKeyValues();
|
||||
QContext.getQSession().withSecurityKeyValue("tenant", "b");
|
||||
var other = hub.connect(QContext.getQSession(), null);
|
||||
other.poll(100);
|
||||
hub.subscriptions(QContext.getQSession(), other.getConnectionId(), java.util.List.of("table:document"), java.util.List.of());
|
||||
login("alice");
|
||||
assertThatThrownBy(() -> service.heartbeat("alice-tab", "document", "01", "EDITING", "title", 1L))
|
||||
.isInstanceOf(QBadRequestException.class).hasMessageContaining("server-issued lease");
|
||||
aliceLease = service.openLease("document", "01");
|
||||
service.renewLease(aliceLease, "document", "01", "EDITING", "title", 1L);
|
||||
var tableHint = viewer.poll(1000);
|
||||
var recordHint = viewer.poll(1000);
|
||||
assertThat(tableHint.event()).isEqualTo("presence.invalidated");
|
||||
assertThat(tableHint.data()).containsOnlyKeys("table");
|
||||
assertThat(recordHint.data()).containsOnlyKeys("table", "pk").containsEntry("pk", "1");
|
||||
assertThat(other.poll(20)).isNull();
|
||||
}
|
||||
admin();
|
||||
var adminPresence = com.kingsrook.qbits.collaboration.app.CollaborationHome.presence();
|
||||
assertThat(adminPresence.getRows()).hasSize(1);
|
||||
assertThat(adminPresence.getRows().getFirst()).containsEntry("table", "Documents").containsEntry("viewers", 1);
|
||||
QContext.getQSession().clearSecurityKeyValues();
|
||||
QContext.getQSession().withSecurityKeyValue("tenant", "b");
|
||||
assertThat(com.kingsrook.qbits.collaboration.app.CollaborationHome.presence().getRows()).isEmpty();
|
||||
login("bob");
|
||||
assertThat(com.kingsrook.qbits.collaboration.app.CollaborationHome.presence().getRows()).isEmpty();
|
||||
assertThat(service.snapshot("document", "1")).hasSize(1);
|
||||
assertThat(service.snapshot("document", "1").getFirst().userId()).isEqualTo("alice");
|
||||
assertThat(service.snapshot("document", "1").getFirst().fieldName()).isEqualTo("title");
|
||||
instance.getTable("document").getField("title").setIsHidden(true);
|
||||
assertThat(service.snapshot("document", "1").getFirst().fieldName()).isNull();
|
||||
String bobLease = service.openLease("document", "1");
|
||||
assertThatThrownBy(() -> service.renewLease(bobLease, "document", "1", "EDITING", "title", 1L))
|
||||
.isInstanceOf(QBadRequestException.class).hasMessageContaining("focused field");
|
||||
assertThatThrownBy(() -> service.snapshot("document", "3")).isInstanceOf(QNotFoundException.class);
|
||||
QContext.getQSession().clearSecurityKeyValues();
|
||||
QContext.getQSession().withSecurityKeyValue("tenant", "b");
|
||||
assertThat(service.snapshot("document", null)).isEmpty();
|
||||
login("alice");
|
||||
service.leave(aliceLease, "document", "1", 2L);
|
||||
assertThat(service.snapshot("document", "1")).isEmpty();
|
||||
service.renewLease(service.openLease("document", "1"), "document", "1", "VIEWING", null, 1L);
|
||||
var client = com.kingsrook.qbits.redis.RedisRuntimeService.connection(instance, "presence").client();
|
||||
// Test-only corruption of the disposable Redis server's record state.
|
||||
client.del(client.keys("*:record:*:state").iterator().next());
|
||||
assertThatThrownBy(() -> service.snapshot("document", "1"))
|
||||
.isInstanceOf(QServiceUnavailableException.class).hasMessage("Presence is temporarily unavailable. Please retry.").hasNoCause();
|
||||
redis.stop();
|
||||
assertThatThrownBy(() -> service.snapshot("document", "1"))
|
||||
.isInstanceOf(QServiceUnavailableException.class).hasMessage("Presence is temporarily unavailable. Please retry.").hasNoCause();
|
||||
assertThatThrownBy(() -> service.heartbeat("tab", "document", "3", "VIEWING", null, 1L))
|
||||
.isInstanceOf(QNotFoundException.class);
|
||||
admin();
|
||||
assertThatThrownBy(com.kingsrook.qbits.collaboration.app.CollaborationHome::presence)
|
||||
.isInstanceOf(QServiceUnavailableException.class);
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.stop();
|
||||
runtime.afterApplicationStop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Tenant identity comes from the authenticated host session, never the request.
|
||||
***************************************************************************/
|
||||
public static class TenantScope implements PresenceScopeProvider
|
||||
{
|
||||
/************************************************************************
|
||||
** Require exactly one authenticated tenant in this fixture.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public String tenantScope()
|
||||
{
|
||||
var tenants = QContext.getQSession().getSecurityKeyValues("tenant");
|
||||
if(tenants.size() != 1)
|
||||
{
|
||||
throw (new IllegalStateException("One tenant required"));
|
||||
}
|
||||
return (tenants.getFirst().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.time.Duration;
|
||||
import com.kingsrook.qbits.collaboration.presence.RedisPresenceStore;
|
||||
import com.kingsrook.qbits.redis.RedisConnection;
|
||||
import com.kingsrook.qbits.redis.api.RedisNamespace;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import redis.clients.jedis.DefaultJedisClientConfig;
|
||||
import redis.clients.jedis.HostAndPort;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Independent clients exercise server-side ordering, expiry and shared snapshots.
|
||||
*******************************************************************************/
|
||||
class RedisPresenceStoreTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** Server-issued leases cross nodes but cannot survive departure or data loss.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void issuedLeaseRequiresLiveAuthority() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
var address = new HostAndPort(redis.getHost(), redis.getMappedPort(6379));
|
||||
var config = DefaultJedisClientConfig.builder().build();
|
||||
try(var first = new RedisConnection(address, config, 2, Duration.ofMillis(500));
|
||||
var second = new RedisConnection(address, config, 2, Duration.ofMillis(500)))
|
||||
{
|
||||
var scope = new RedisNamespace("test", "app", "issued", "presence");
|
||||
var a = new RedisPresenceStore(first.scripts(), scope, Duration.ofSeconds(45), 10);
|
||||
var b = new RedisPresenceStore(second.scripts(), scope, Duration.ofSeconds(45), 10);
|
||||
String lease = a.openLease("one", "One", "orders", "1");
|
||||
assertThat(lease).startsWith("lease:");
|
||||
assertThat(b.snapshot("orders", "1")).isEmpty();
|
||||
assertThat(b.snapshot("orders", null)).isEmpty();
|
||||
assertThatThrownBy(() -> b.renewLease("other", "Other", lease, "orders", "1", "EDITING", null, 1L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThatThrownBy(() -> b.renewLease("one", "One", lease, "orders", "2", "EDITING", null, 1L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(b.renewLease("one", "One", lease, "orders", "1", "EDITING", "status", 1L)).isTrue();
|
||||
var expiryKey = first.client().keys("*:record:*:expiry").iterator().next();
|
||||
var expiryBefore = first.client().zrangeWithScores(expiryKey, 0, -1);
|
||||
assertThat(a.renewLease("one", "One", lease, "orders", "1", "VIEWING", null, 1L)).isTrue();
|
||||
assertThat(first.client().zrangeWithScores(expiryKey, 0, -1)).isEqualTo(expiryBefore);
|
||||
assertThat(a.snapshot("orders", "1")).hasSize(1);
|
||||
assertThat(a.snapshot("orders", "1").getFirst().mode()).isEqualTo("EDITING");
|
||||
assertThat(a.snapshot("orders", null)).allSatisfy(viewer -> assertThat(viewer.fieldName()).isNull());
|
||||
a.leave("one", lease, "orders", "1", 3L);
|
||||
assertThatThrownBy(() -> b.renewLease("one", "One", lease, "orders", "1", "EDITING", null, 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThatThrownBy(() -> b.renewLease("one", "One", lease, "orders", "1", "EDITING", null, 4L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
|
||||
String replacement = b.openLease("one", "One", "orders", "1");
|
||||
assertThat(replacement).isNotEqualTo(lease);
|
||||
assertThat(a.renewLease("one", "One", replacement, "orders", "1", "VIEWING", null, 1L)).isTrue();
|
||||
first.client().flushDB();
|
||||
assertThatThrownBy(() -> b.renewLease("one", "One", replacement, "orders", "1", "VIEWING", null, 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThatThrownBy(() -> b.heartbeat("one", "One", replacement, "orders", "1", "VIEWING", null, 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(first.client().dbSize()).isZero();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Abandoned reservations consume bounded capacity and expire invisibly.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void issuedLeaseExpiryDoesNotRecreateMembership() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
try(var connection = new RedisConnection(new HostAndPort(redis.getHost(), redis.getMappedPort(6379)),
|
||||
DefaultJedisClientConfig.builder().build(), 2, Duration.ofMillis(500)))
|
||||
{
|
||||
var store = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", "issued-expiry", "presence"), Duration.ofMillis(150), 1);
|
||||
String pending = store.openLease("one", "One", "orders", "1");
|
||||
assertThat(store.openLease("two", "Two", "orders", "2")).isNull();
|
||||
assertThat(store.snapshot("orders", null)).isEmpty();
|
||||
Thread.sleep(250);
|
||||
assertThatThrownBy(() -> store.renewLease("one", "One", pending, "orders", "1", "EDITING", null, 1L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
String active = store.openLease("two", "Two", "orders", "2");
|
||||
assertThat(store.renewLease("two", "Two", active, "orders", "2", "EDITING", null, 1L)).isTrue();
|
||||
Thread.sleep(250);
|
||||
assertThatThrownBy(() -> store.renewLease("two", "Two", active, "orders", "2", "EDITING", null, 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(store.snapshot("orders", null)).isEmpty();
|
||||
String departed = store.openLease("two", "Two", "orders", "2");
|
||||
assertThat(departed).isNotNull();
|
||||
store.renewLease("two", "Two", departed, "orders", "2", "EDITING", null, 1L);
|
||||
store.leave("two", departed, "orders", "2", 3L);
|
||||
Thread.sleep(250);
|
||||
assertThatThrownBy(() -> store.renewLease("two", "Two", departed, "orders", "2", "EDITING", null, 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(store.snapshot("orders", null)).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Characterize the open recovery gap: all projections lost together look new.
|
||||
** This is evidence for #1295, not an assertion of the desired final contract.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void completeDataLossCurrentlyAcceptsDelayedHeartbeat() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
var address = new HostAndPort(redis.getHost(), redis.getMappedPort(6379));
|
||||
var config = DefaultJedisClientConfig.builder().build();
|
||||
try(var first = new RedisConnection(address, config, 2, Duration.ofMillis(500));
|
||||
var second = new RedisConnection(address, config, 2, Duration.ofMillis(500)))
|
||||
{
|
||||
var scope = new RedisNamespace("test", "app", "recovery", "presence");
|
||||
var a = new RedisPresenceStore(first.scripts(), scope, Duration.ofSeconds(45), 10);
|
||||
var b = new RedisPresenceStore(second.scripts(), scope, Duration.ofSeconds(45), 10);
|
||||
a.heartbeat("active", "Active", "active-tab", "orders", "1", "VIEWING", null, 1L);
|
||||
a.heartbeat("departed", "Departed", "old-tab", "orders", "1", "EDITING", null, 1L);
|
||||
b.leave("departed", "old-tab", "orders", "1", 3L);
|
||||
assertThat(b.snapshot("orders", "1")).extracting("userId").containsExactly("active");
|
||||
|
||||
// Only this disposable test server is cleared. Both clients stay alive.
|
||||
first.client().flushDB();
|
||||
assertThat(b.snapshot("orders", "1")).isEmpty();
|
||||
assertThat(a.heartbeat("departed", "Departed", "old-tab", "orders", "1", "EDITING", null, 2L)).isTrue();
|
||||
assertThat(b.snapshot("orders", "1")).extracting("userId").containsExactly("departed");
|
||||
assertThat(b.snapshot("orders", null)).extracting("userId").containsExactly("departed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Characterize the separate ordering gap after a departure marker expires.
|
||||
** A newer room member keeps both projections alive throughout this test.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void expiredDepartureCurrentlyAcceptsDelayedHeartbeat() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
try(var connection = new RedisConnection(new HostAndPort(redis.getHost(), redis.getMappedPort(6379)),
|
||||
DefaultJedisClientConfig.builder().build(), 2, Duration.ofMillis(500)))
|
||||
{
|
||||
var store = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", "late", "presence"), Duration.ofMillis(150), 10);
|
||||
store.heartbeat("departed", "Departed", "tab", "orders", "1", "EDITING", null, 1L);
|
||||
store.leave("departed", "tab", "orders", "1", 3L);
|
||||
store.heartbeat("departed", "Departed", "tab", "orders", "1", "EDITING", null, 2L);
|
||||
assertThat(store.snapshot("orders", "1")).isEmpty();
|
||||
|
||||
// Longer-lived peer proves this does not depend on losing entire keys.
|
||||
var peer = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", "late", "presence"), Duration.ofSeconds(45), 10);
|
||||
peer.heartbeat("active", "Active", "peer-tab", "orders", "1", "VIEWING", null, 1L);
|
||||
Thread.sleep(250);
|
||||
assertThat(peer.snapshot("orders", "1")).extracting("userId").containsExactly("active");
|
||||
store.heartbeat("departed", "Departed", "tab", "orders", "1", "EDITING", null, 2L);
|
||||
assertThat(peer.snapshot("orders", "1")).extracting("userId").containsExactly("active", "departed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Six people share one record, with two editing; tabs deduplicate by user.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void sharedRoomOrderingIsolationAndExpiry() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
var address = new HostAndPort(redis.getHost(), redis.getMappedPort(6379));
|
||||
var config = DefaultJedisClientConfig.builder().connectionTimeoutMillis(500).socketTimeoutMillis(500).build();
|
||||
try(var first = new RedisConnection(address, config, 4, Duration.ofMillis(500));
|
||||
var second = new RedisConnection(address, config, 4, Duration.ofMillis(500)))
|
||||
{
|
||||
var scope = new RedisNamespace("test", "app", "tenant-a", "presence");
|
||||
var a = new RedisPresenceStore(first.scripts(), scope, Duration.ofSeconds(45), 100);
|
||||
var b = new RedisPresenceStore(second.scripts(), scope, Duration.ofSeconds(45), 100);
|
||||
for(int i = 0; i < 6; i++)
|
||||
{
|
||||
assertThat((i % 2 == 0 ? a : b).heartbeat("user-" + i, "Person " + i, "tab-" + i, "orders", "123",
|
||||
i < 2 ? "EDITING" : "VIEWING", i < 2 ? "status" : null, 1L)).isTrue();
|
||||
}
|
||||
assertThat(b.snapshot("orders", "123")).hasSize(6).isEqualTo(a.snapshot("orders", "123"));
|
||||
assertThat(b.snapshot("orders", "123").stream().filter(viewer -> viewer.mode().equals("EDITING"))).hasSize(2);
|
||||
assertThat(b.snapshot("orders", null)).hasSize(6).allSatisfy(viewer -> assertThat(viewer.fieldName()).isNull());
|
||||
assertThat(a.heartbeat("user-0", "Person 0", "extra-tab", "orders", "123", "VIEWING", null, 1L)).isTrue();
|
||||
assertThat(b.snapshot("orders", "123")).hasSize(6);
|
||||
assertThat(b.snapshot("orders", "123").getFirst().mode()).isEqualTo("EDITING");
|
||||
assertThat(b.leave("user-0", "tab-0", "orders", "123", 3L)).isTrue();
|
||||
assertThat(a.heartbeat("user-0", "Person 0", "tab-0", "orders", "123", "EDITING", "status", 2L)).isTrue();
|
||||
assertThat(a.snapshot("orders", "123").getFirst().mode()).isEqualTo("VIEWING");
|
||||
assertThat(a.heartbeat("user-0", "Person 0", "tab-0", "orders", "123", "EDITING", "status", 3L)).isTrue();
|
||||
assertThat(a.snapshot("orders", "123").getFirst().mode()).isEqualTo("VIEWING");
|
||||
assertThat(a.heartbeat("user-0", "Person 0", "tab-0", "orders", "123", "EDITING", "status", 4L)).isTrue();
|
||||
assertThat(b.snapshot("orders", "123").getFirst().mode()).isEqualTo("EDITING");
|
||||
var tenantB = new RedisPresenceStore(second.scripts(), new RedisNamespace("test", "app", "tenant-b", "presence"), Duration.ofSeconds(45), 100);
|
||||
assertThat(tenantB.snapshot("orders", "123")).isEmpty();
|
||||
assertThat(a.snapshot("orders", "124")).isEmpty();
|
||||
|
||||
try(var writers = java.util.concurrent.Executors.newFixedThreadPool(4))
|
||||
{
|
||||
var writes = new java.util.ArrayList<java.util.concurrent.Future<Boolean>>();
|
||||
for(long sequence = 10; sequence < 50; sequence++)
|
||||
{
|
||||
final long requestSequence = sequence;
|
||||
writes.add(writers.submit(() -> (requestSequence % 2 == 0 ? a : b).heartbeat("racing-user", "Racing", "racing-tab",
|
||||
"orders", "124", requestSequence == 49 ? "EDITING" : "VIEWING", null, requestSequence)));
|
||||
}
|
||||
for(var write : writes)
|
||||
{
|
||||
assertThat(write.get(10, java.util.concurrent.TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
}
|
||||
assertThat(a.snapshot("orders", "124")).hasSize(1);
|
||||
assertThat(a.snapshot("orders", "124").getFirst().mode()).isEqualTo("EDITING");
|
||||
assertThat(b.leave("racing-user", "racing-tab", "orders", "124", 50L)).isTrue();
|
||||
assertThat(a.heartbeat("racing-user", "Racing", "racing-tab", "orders", "124", "EDITING", null, 49L)).isTrue();
|
||||
assertThat(a.snapshot("orders", "124")).isEmpty();
|
||||
|
||||
var shortLease = new RedisPresenceStore(first.scripts(), new RedisNamespace("test", "app", "short", "presence"), Duration.ofMillis(150), 10);
|
||||
assertThat(shortLease.heartbeat("user", "Person", "tab", "orders", "123", "EDITING", "status", 1L)).isTrue();
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
|
||||
while(!shortLease.snapshot("orders", "123").isEmpty() && System.nanoTime() < deadline)
|
||||
{
|
||||
Thread.sleep(20);
|
||||
}
|
||||
assertThat(shortLease.snapshot("orders", "123")).isEmpty();
|
||||
assertThat(shortLease.snapshot("orders", null)).isEmpty();
|
||||
redis.execInContainer("redis-cli", "SCRIPT", "FLUSH");
|
||||
assertThat(b.snapshot("orders", "123")).hasSize(6);
|
||||
redis.stop();
|
||||
assertThatThrownBy(() -> a.snapshot("orders", "123")).isInstanceOf(redis.clients.jedis.exceptions.JedisException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A full table cannot leave a half-written record membership behind.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void capacityChecksAreAtomic() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
try(var connection = new RedisConnection(new HostAndPort(redis.getHost(), redis.getMappedPort(6379)),
|
||||
DefaultJedisClientConfig.builder().build(), 2, Duration.ofMillis(500)))
|
||||
{
|
||||
var store = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", "capacity", "presence"), Duration.ofSeconds(45), 1);
|
||||
assertThat(store.heartbeat("one", "One", "tab", "orders", "1", "VIEWING", null, 1L)).isTrue();
|
||||
assertThat(store.heartbeat("two", "Two", "tab", "orders", "2", "VIEWING", null, 1L)).isFalse();
|
||||
assertThat(store.snapshot("orders", "2")).isEmpty();
|
||||
assertThat(store.snapshot("orders", null)).hasSize(1);
|
||||
assertThat(store.leave("one", "tab", "orders", "1", 2L)).isTrue();
|
||||
assertThat(store.snapshot("orders", null)).isEmpty();
|
||||
assertThat(store.heartbeat("two", "Two", "tab", "orders", "2", "VIEWING", null, 1L)).isFalse();
|
||||
assertThatThrownBy(() -> store.heartbeat("one", "One", "tab", "orders", "1", "INVALID", null, 3L)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Losing one half of a room must not report empty or partially renew a lease.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void partialRoomLossIsUnavailable() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
try(var connection = new RedisConnection(new HostAndPort(redis.getHost(), redis.getMappedPort(6379)),
|
||||
DefaultJedisClientConfig.builder().build(), 2, Duration.ofMillis(500)))
|
||||
{
|
||||
for(String suffix : java.util.List.of("state", "expiry"))
|
||||
{
|
||||
var store = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", suffix, "presence"), Duration.ofSeconds(45), 10);
|
||||
assertThat(store.heartbeat("one", "One", "tab", "orders", "1", "VIEWING", null, 1L)).isTrue();
|
||||
// KEYS is confined to this disposable test server, never the application path.
|
||||
String recordKey = connection.client().keys("*:record:*:" + suffix).iterator().next();
|
||||
String tableKey = recordKey.substring(0, recordKey.indexOf(":record:")) + ":table:state";
|
||||
var before = connection.client().hgetAll(tableKey);
|
||||
connection.client().del(recordKey);
|
||||
assertThatThrownBy(() -> store.snapshot("orders", "1"))
|
||||
.isInstanceOf(redis.clients.jedis.exceptions.JedisException.class).hasMessageContaining("incomplete");
|
||||
assertThatThrownBy(() -> store.heartbeat("one", "One", "tab", "orders", "1", "EDITING", "status", 2L))
|
||||
.isInstanceOf(redis.clients.jedis.exceptions.JedisException.class).hasMessageContaining("incomplete");
|
||||
assertThat(connection.client().hgetAll(tableKey)).isEqualTo(before);
|
||||
connection.client().flushDB();
|
||||
}
|
||||
for(String room : java.util.List.of("record", "table"))
|
||||
{
|
||||
var store = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", room, "presence"), Duration.ofSeconds(45), 10);
|
||||
store.heartbeat("one", "One", "tab", "orders", "1", "VIEWING", null, 1L);
|
||||
store.leave("one", "tab", "orders", "1", 3L);
|
||||
var lostKeys = connection.client().keys(room.equals("record") ? "*:record:*" : "*:table:*");
|
||||
connection.client().del(lostKeys.toArray(String[]::new));
|
||||
assertThatThrownBy(() -> store.snapshot("orders", "1"))
|
||||
.isInstanceOf(redis.clients.jedis.exceptions.JedisException.class).hasMessageContaining("incomplete");
|
||||
assertThatThrownBy(() -> store.heartbeat("one", "One", "tab", "orders", "1", "EDITING", null, 2L))
|
||||
.isInstanceOf(redis.clients.jedis.exceptions.JedisException.class).hasMessageContaining("incomplete");
|
||||
assertThat(connection.client().keys(room.equals("record") ? "*:record:*" : "*:table:*")).isEmpty();
|
||||
connection.client().flushDB();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,11 +389,16 @@ public class SharingWorkTest extends PersonalServiceFixture
|
||||
@Test
|
||||
void assignmentsUpsertAndMyRecordsRespectParentLocks() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> assignments.assignRoles("document", 1, null, null, java.util.Set.of())).hasMessageContaining("Supply ownerUserId");
|
||||
assertThatThrownBy(() -> assignments.assign("document", 1, "eve", null)).hasMessageContaining("can't open");
|
||||
QRecord first = assignments.assign("document", 1, "alice", "bob");
|
||||
QRecord again = assignments.assign("document", "01", "alice", "bob");
|
||||
assertThat(again.getValue("id")).isEqualTo(first.getValue("id"));
|
||||
assertThat(recorder.notifications()).hasSize(1);
|
||||
QRecord ownerOnly = assignments.assignRoles("document", 1, null, null, java.util.Set.of("ownerUserId"));
|
||||
assertThat(ownerOnly.getValueString("ownerUserId")).isNull();
|
||||
assertThat(ownerOnly.getValueString("assigneeUserId")).isEqualTo("bob");
|
||||
assignments.assignRoles("document", 1, "alice", null, java.util.Set.of("ownerUserId"));
|
||||
assignments.assign("document", 2, null, "alice");
|
||||
assertThat(assignments.myRecords(null, 1).get("document").count()).isEqualTo(2);
|
||||
assertThat(assignments.myRecords("document", 1).get("document").records()).hasSize(1);
|
||||
@@ -404,6 +409,21 @@ public class SharingWorkTest extends PersonalServiceFixture
|
||||
assertThat(instance.getTable(CollaborationTables.RECORD_ASSIGNMENT).getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The fallback API cannot create a competing owner for native ownership metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fallbackAssignmentsRejectNativeOwnership() throws Exception
|
||||
{
|
||||
var metadata = com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document"));
|
||||
metadata.withOwnership(new com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordOwnership().withOwnerFieldName("title"));
|
||||
com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay.refresh(instance);
|
||||
assertThatThrownBy(() -> assignments.assignRoles("document", 1, "alice", null, java.util.Set.of("ownerUserId")))
|
||||
.hasMessageContaining("native ownership");
|
||||
assertThat(assignments.get("document", 1)).isEmpty();
|
||||
assertThat(assignments.assignRoles("document", 1, null, "bob", java.util.Set.of("assigneeUserId")).getValueString("assigneeUserId")).isEqualTo("bob");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** My Work Summarizes Only Visible Work And Read Mentions.
|
||||
*******************************************************************************/
|
||||
|
||||
+666
@@ -0,0 +1,666 @@
|
||||
/*
|
||||
* 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.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSchema;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.wait.strategy.Wait;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real PostgreSQL transaction boundaries with deterministic storage fault injection.
|
||||
*******************************************************************************/
|
||||
class AttachmentTransactionsPostgresTest extends PersonalServiceFixture
|
||||
{
|
||||
private static GenericContainer<?> postgres;
|
||||
private RecordingStorage storage;
|
||||
private AttachmentsService service;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An isolated database container is required; never use an application database.
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void startPostgres()
|
||||
{
|
||||
postgres = new GenericContainer<>("postgres:17-alpine")
|
||||
.withEnv("POSTGRES_PASSWORD", "attachment-fixture-only").withExposedPorts(5432)
|
||||
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*", 2));
|
||||
postgres.start();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop only the database owned by this fixture.
|
||||
*******************************************************************************/
|
||||
@AfterAll
|
||||
static void stopPostgres()
|
||||
{
|
||||
if(postgres != null)
|
||||
{
|
||||
postgres.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse the authorization fixture, replacing its backend with real PostgreSQL.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception
|
||||
{
|
||||
super.setUp();
|
||||
instance.getBackends().remove("memory");
|
||||
instance.addBackend(new PostgreSQLBackendMetaData().withName("memory").withHostName(postgres.getHost())
|
||||
.withPort(postgres.getMappedPort(5432)).withDatabaseName("postgres")
|
||||
.withUsername("postgres").withPassword("attachment-fixture-only"));
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public");
|
||||
sql.execute(CollaborationSchema.postgresScript(config));
|
||||
sql.execute("CREATE TABLE document (id INTEGER PRIMARY KEY, title TEXT, tenant TEXT)");
|
||||
sql.execute("INSERT INTO document VALUES (1, 'Visible', 'a'), (3, 'Private', 'b')");
|
||||
}
|
||||
storage = new RecordingStorage();
|
||||
service = new AttachmentsService(config, storage);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Close owner connections before the isolated schema is replaced.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void tearDown()
|
||||
{
|
||||
ConnectionManager.closeConnectionProviders(instance);
|
||||
super.tearDown();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Later outer rollback removes both the SQL row and the uploaded object.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void uploadOuterRollbackCompensatesObject() throws Exception
|
||||
{
|
||||
try(var tx = new RDBMSTransaction(connect()))
|
||||
{
|
||||
QContext.setTransaction(tx);
|
||||
upload();
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
tx.rollback();
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
assertThat(rowCount()).isZero();
|
||||
assertThat(storage.objects).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A rejected outer transaction cannot delete bytes from an existing attachment.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deleteOuterRollbackPreservesAttachment() throws Exception
|
||||
{
|
||||
AttachmentEntry entry = upload();
|
||||
try(var tx = new RDBMSTransaction(connect()))
|
||||
{
|
||||
QContext.setTransaction(tx);
|
||||
service.delete(List.of(entry.id()));
|
||||
tx.rollback();
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
assertThat(rowCount()).isEqualTo(1);
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
assertThat(pendingCount()).isZero();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A committed deletion removes metadata and bytes after the transaction commits.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deleteCommitRemovesAttachment() throws Exception
|
||||
{
|
||||
AttachmentEntry entry = upload();
|
||||
try(var tx = new RDBMSTransaction(connect()))
|
||||
{
|
||||
QContext.setTransaction(tx);
|
||||
service.delete(List.of(entry.id()));
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
tx.commit();
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
assertThat(rowCount()).isZero();
|
||||
assertThat(storage.objects).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Database rejection must not leave a visible row whose bytes were already deleted.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void metadataDeleteFailurePreservesDownloadableObject() throws Exception
|
||||
{
|
||||
AttachmentEntry entry = upload();
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("CREATE FUNCTION reject_attachment_delete() RETURNS trigger LANGUAGE plpgsql AS $$ "
|
||||
+ "BEGIN RAISE EXCEPTION 'injected metadata delete rejection'; END $$");
|
||||
sql.execute("CREATE TRIGGER reject_attachment_delete BEFORE DELETE ON collaboration_attachment "
|
||||
+ "FOR EACH ROW EXECUTE FUNCTION reject_attachment_delete()");
|
||||
}
|
||||
assertThatThrownBy(() -> service.delete(List.of(entry.id()))).isInstanceOf(Exception.class);
|
||||
assertThat(rowCount()).isEqualTo(1);
|
||||
assertThat(storage.objects).as("a retained visible attachment must retain its bytes").hasSize(1);
|
||||
assertThat(pendingCount()).isZero();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider failure leaves a durable locator; a new worker can retry after restart.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void storageDeleteFailureRetainsIntentAndRetrySucceeds() throws Exception
|
||||
{
|
||||
AttachmentEntry entry = upload();
|
||||
storage.failDelete = true;
|
||||
assertThatThrownBy(() -> service.delete(List.of(entry.id()))).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(rowCount()).isZero();
|
||||
assertThat(pendingCount()).isEqualTo(1);
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
storage.failDelete = false;
|
||||
assertThatThrownBy(() -> new AttachmentCleanupQueue(storage).retryPending())
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
||||
admin();
|
||||
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isEqualTo(1);
|
||||
assertThat(rowCount()).isZero();
|
||||
assertThat(pendingCount()).isZero();
|
||||
assertThat(storage.objects).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A database commit survives loss of every in-process post-commit callback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void committedIntentSurvivesLostCallback() throws Exception
|
||||
{
|
||||
AttachmentEntry entry = upload();
|
||||
try(var tx = new RDBMSTransaction(connect())
|
||||
{
|
||||
/***********************************************************************
|
||||
** Simulate process loss after commit, before any callback can execute.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void addAfterCommitCallback(Runnable callback)
|
||||
{
|
||||
}
|
||||
})
|
||||
{
|
||||
QContext.setTransaction(tx);
|
||||
service.delete(List.of(entry.id()));
|
||||
tx.commit();
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
assertThat(rowCount()).isZero();
|
||||
assertThat(pendingCount()).isEqualTo(1);
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
admin();
|
||||
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isEqualTo(1);
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(pendingCount()).isZero();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Losing queue acknowledgement after deleting bytes must remain replayable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void queueAcknowledgementFailureIsReplayable() throws Exception
|
||||
{
|
||||
AttachmentEntry entry = upload();
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("CREATE FUNCTION reject_cleanup_ack() RETURNS trigger LANGUAGE plpgsql AS $$ "
|
||||
+ "BEGIN RAISE EXCEPTION 'injected cleanup acknowledgement rejection'; END $$");
|
||||
sql.execute("CREATE TRIGGER reject_cleanup_ack BEFORE DELETE ON collaboration_attachment_cleanup "
|
||||
+ "FOR EACH ROW EXECUTE FUNCTION reject_cleanup_ack()");
|
||||
}
|
||||
assertThatThrownBy(() -> service.delete(List.of(entry.id()))).isInstanceOf(Exception.class);
|
||||
assertThat(rowCount()).isZero();
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(pendingCount()).isEqualTo(1);
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("DROP TRIGGER reject_cleanup_ack ON collaboration_attachment_cleanup");
|
||||
}
|
||||
admin();
|
||||
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isEqualTo(1);
|
||||
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isZero();
|
||||
assertThat(pendingCount()).isZero();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue observations use independent committed database state.
|
||||
*******************************************************************************/
|
||||
private int pendingCount() throws Exception
|
||||
{
|
||||
try(Connection connection = connect(); var sql = connection.createStatement();
|
||||
var rows = sql.executeQuery("SELECT COUNT(*) FROM collaboration_attachment_cleanup"))
|
||||
{
|
||||
rows.next();
|
||||
return (rows.getInt(1));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed first-page entries must not starve later healthy cleanup requests.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failedBatchDoesNotStarveLaterRequests() throws Exception
|
||||
{
|
||||
try(Connection connection = connect(); var sql = connection.prepareStatement(
|
||||
"INSERT INTO collaboration_attachment_cleanup (attachment_id, bucket_name, storage_key) VALUES (?, 'collaboration', ?)"))
|
||||
{
|
||||
for(int id = 1; id <= 101; id++)
|
||||
{
|
||||
String key = "queued/" + id;
|
||||
sql.setInt(1, id);
|
||||
sql.setString(2, key);
|
||||
sql.executeUpdate();
|
||||
storage.objects.put("collaboration/" + key, new byte[] { 1 });
|
||||
if(id <= 100)
|
||||
{
|
||||
storage.failedKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
admin();
|
||||
assertThatThrownBy(() -> new AttachmentCleanupQueue(storage).retryPending()).isInstanceOf(Exception.class);
|
||||
assertThat(storage.deleteAttempts.get()).isEqualTo(100);
|
||||
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isEqualTo(1);
|
||||
assertThat(storage.deleteAttempts.get()).isEqualTo(101);
|
||||
assertThat(pendingCount()).isEqualTo(100);
|
||||
assertThat(storage.objects).doesNotContainKey("collaboration/queued/101");
|
||||
storage.failedKeys.clear();
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("UPDATE collaboration_attachment_cleanup SET next_attempt_at = CURRENT_TIMESTAMP - INTERVAL '1 second'");
|
||||
}
|
||||
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isEqualTo(100);
|
||||
assertThat(pendingCount()).isZero();
|
||||
assertThat(storage.objects).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed rollback compensation must preserve a durable locator for later cleanup.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failedUploadRollbackCompensationRemainsRetryable() throws Exception
|
||||
{
|
||||
try(var tx = new RDBMSTransaction(connect()))
|
||||
{
|
||||
QContext.setTransaction(tx);
|
||||
upload();
|
||||
storage.failDelete = true;
|
||||
tx.rollback();
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
assertThat(rowCount()).isZero();
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
assertThat(pendingCount()).isEqualTo(1);
|
||||
storage.failDelete = false;
|
||||
admin();
|
||||
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isEqualTo(1);
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(pendingCount()).isZero();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Upgrading the first queue shape preserves pending deletions and is repeatable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cleanupSchemaUpgradePreservesPendingRequests() throws Exception
|
||||
{
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("ALTER TABLE collaboration_attachment_cleanup DROP COLUMN next_attempt_at");
|
||||
sql.execute("ALTER TABLE collaboration_attachment_cleanup ALTER COLUMN attachment_id SET NOT NULL");
|
||||
sql.execute("CREATE UNIQUE INDEX uq_collaboration_attachment_cleanup_1 ON collaboration_attachment_cleanup (attachment_id)");
|
||||
sql.execute("INSERT INTO collaboration_attachment_cleanup (attachment_id, bucket_name, storage_key) VALUES (42, 'collaboration', 'pending/42')");
|
||||
sql.execute(CollaborationSchema.postgresScript(config));
|
||||
sql.execute(CollaborationSchema.postgresScript(config));
|
||||
sql.execute("INSERT INTO collaboration_attachment_cleanup (bucket_name, storage_key) VALUES ('collaboration', 'rollback/one'), ('collaboration', 'rollback/two')");
|
||||
}
|
||||
assertThat(pendingCount()).isEqualTo(3);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider may store bytes and then fail before acknowledging the upload.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ambiguousUploadFailureRetainsCompensation() throws Exception
|
||||
{
|
||||
storage.failPutAfterWrite = true;
|
||||
storage.failDelete = true;
|
||||
assertThatThrownBy(this::upload).isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("injected upload acknowledgement failure");
|
||||
assertThat(rowCount()).isZero();
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
assertThat(pendingCount()).isEqualTo(1);
|
||||
storage.failDelete = false;
|
||||
admin();
|
||||
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isEqualTo(1);
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(pendingCount()).isZero();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replay real provider deletes after the database rejects queue acknowledgement.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void minioDeletionReplayPreservesOtherObjects() throws Exception
|
||||
{
|
||||
try(var minio = new GenericContainer<>("cgr.dev/chainguard/minio@sha256:dc455cdd4db6e390b1b26514f15ff37681a777bf8dab41aac445a0259f751e56")
|
||||
.withEnv("MINIO_ROOT_USER", "collaboration-test").withEnv("MINIO_ROOT_PASSWORD", "collaboration-test-password")
|
||||
.withCommand("server", "/tmp/minio-data").withExposedPorts(9000)
|
||||
.waitingFor(Wait.forHttp("/minio/health/live").forPort(9000)))
|
||||
{
|
||||
minio.start();
|
||||
String endpoint = "http://" + minio.getHost() + ":" + minio.getMappedPort(9000);
|
||||
try(var client = software.amazon.awssdk.services.s3.S3Client.builder()
|
||||
.endpointOverride(java.net.URI.create(endpoint)).region(software.amazon.awssdk.regions.Region.US_EAST_1)
|
||||
.forcePathStyle(true).credentialsProvider(software.amazon.awssdk.auth.credentials.StaticCredentialsProvider.create(
|
||||
software.amazon.awssdk.auth.credentials.AwsBasicCredentials.create("collaboration-test", "collaboration-test-password"))).build())
|
||||
{
|
||||
client.createBucket(b -> b.bucket("collaboration-plain"));
|
||||
client.createBucket(b -> b.bucket("collaboration-versioned"));
|
||||
client.putBucketVersioning(b -> b.bucket("collaboration-versioned").versioningConfiguration(v ->
|
||||
v.status(software.amazon.awssdk.services.s3.model.BucketVersioningStatus.ENABLED)));
|
||||
System.setProperty("collaboration.minio.access", "collaboration-test");
|
||||
System.setProperty("collaboration.minio.secret", "collaboration-test-password");
|
||||
var storageConfig = new com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig()
|
||||
.withBackendName("memory")
|
||||
.withAllowedSecretRefPatterns(List.of("prop.collaboration.minio.*"))
|
||||
.withBucketScoping(false)
|
||||
.withConnection(new com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig("fixture")
|
||||
.withEndpoint(endpoint).withRegion("us-east-1")
|
||||
.withAddressingStyle(com.kingsrook.qbits.objectstorage.config.AddressingStyle.PATH)
|
||||
.withChecksumMode(com.kingsrook.qbits.objectstorage.config.ChecksumMode.WHEN_REQUIRED)
|
||||
.withAccessKeyRef("${prop.collaboration.minio.access}").withSecretKeyRef("${prop.collaboration.minio.secret}"));
|
||||
for(String alias : List.of("plain", "versioned"))
|
||||
{
|
||||
storageConfig.withBucket(new com.kingsrook.qbits.objectstorage.config.StorageBucketConfig(alias)
|
||||
.withConnection("fixture").withBucketName("collaboration-" + alias)
|
||||
.withRole(com.kingsrook.qbits.objectstorage.config.BucketRole.PRIVATE).withIndexed(false));
|
||||
}
|
||||
com.kingsrook.qbits.objectstorage.api.ObjectStorage.configureStandalone(storageConfig);
|
||||
for(String alias : List.of("plain", "versioned"))
|
||||
{
|
||||
config.withAttachmentsBucketName(alias);
|
||||
service = new AttachmentsService(config, new AttachmentStorage());
|
||||
AttachmentEntry removed = upload();
|
||||
AttachmentEntry retained = upload();
|
||||
String removedKey = storedKey(removed.id());
|
||||
String retainedKey = storedKey(retained.id());
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("CREATE OR REPLACE FUNCTION reject_provider_ack() RETURNS trigger LANGUAGE plpgsql AS $$ "
|
||||
+ "BEGIN RAISE EXCEPTION 'injected provider acknowledgement rejection'; END $$");
|
||||
sql.execute("CREATE TRIGGER reject_provider_ack BEFORE DELETE ON collaboration_attachment_cleanup "
|
||||
+ "FOR EACH ROW EXECUTE FUNCTION reject_provider_ack()");
|
||||
}
|
||||
assertThatThrownBy(() -> service.delete(List.of(removed.id()))).isInstanceOf(Exception.class);
|
||||
assertThat(pendingCount()).isEqualTo(1);
|
||||
try(Connection connection = connect(); var sql = connection.createStatement())
|
||||
{
|
||||
sql.execute("DROP TRIGGER reject_provider_ack ON collaboration_attachment_cleanup");
|
||||
}
|
||||
admin();
|
||||
assertThat(new AttachmentCleanupQueue(new AttachmentStorage()).retryPending()).isEqualTo(1);
|
||||
assertThat(pendingCount()).isZero();
|
||||
var bucket = com.kingsrook.qbits.objectstorage.api.ObjectStorage.asSystem("collaboration-test", "replay").bucket(alias);
|
||||
assertThat(bucket.head(removedKey)).isEmpty();
|
||||
assertThat(bucket.head(retainedKey)).isPresent();
|
||||
assertThat(client.getObjectAsBytes(b -> b.bucket("collaboration-" + alias).key(retainedKey)).asByteArray())
|
||||
.containsExactly((byte) 1, (byte) 2, (byte) 3);
|
||||
service.delete(List.of(retained.id()));
|
||||
login("alice");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime.reset();
|
||||
com.kingsrook.qbits.objectstorage.internal.ConfigSync.reset();
|
||||
System.clearProperty("collaboration.minio.access");
|
||||
System.clearProperty("collaboration.minio.secret");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Observe the immutable provider key from committed SQL metadata.
|
||||
*******************************************************************************/
|
||||
private String storedKey(int attachmentId) throws Exception
|
||||
{
|
||||
try(Connection connection = connect(); var sql = connection.prepareStatement("SELECT storage_key FROM collaboration_attachment WHERE id = ?"))
|
||||
{
|
||||
sql.setInt(1, attachmentId);
|
||||
try(var rows = sql.executeQuery())
|
||||
{
|
||||
assertThat(rows.next()).isTrue();
|
||||
return (rows.getString(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Two workers may observe the same committed intent; replay must remain safe.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void concurrentWorkersCanReplayTheSameIntent() throws Exception
|
||||
{
|
||||
AttachmentEntry entry = upload();
|
||||
storage.failDelete = true;
|
||||
assertThatThrownBy(() -> service.delete(List.of(entry.id()))).isInstanceOf(IllegalStateException.class);
|
||||
storage.failDelete = false;
|
||||
storage.concurrentDeletes = new java.util.concurrent.CountDownLatch(2);
|
||||
admin();
|
||||
var session = QContext.getQSession();
|
||||
try(var executor = java.util.concurrent.Executors.newFixedThreadPool(2))
|
||||
{
|
||||
java.util.concurrent.Callable<Integer> retry = () ->
|
||||
{
|
||||
QContext.init(instance, session);
|
||||
try
|
||||
{
|
||||
return (new AttachmentCleanupQueue(storage).retryPending());
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
};
|
||||
var first = executor.submit(retry);
|
||||
var second = executor.submit(retry);
|
||||
assertThat(first.get(20, java.util.concurrent.TimeUnit.SECONDS)).isEqualTo(1);
|
||||
assertThat(second.get(20, java.util.concurrent.TimeUnit.SECONDS)).isEqualTo(1);
|
||||
}
|
||||
assertThat(rowCount()).isZero();
|
||||
assertThat(pendingCount()).isZero();
|
||||
assertThat(storage.objects).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recovery locations are admin-only and cannot be forged through table writes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cleanupQueueIsServiceManaged() throws Exception
|
||||
{
|
||||
var query = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(
|
||||
com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT_CLEANUP);
|
||||
assertThatThrownBy(() -> new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
||||
admin();
|
||||
assertThat(new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query).getRecords()).isEmpty();
|
||||
var insert = new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput(
|
||||
com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT_CLEANUP)
|
||||
.withRecords(List.of(new com.kingsrook.qqq.backend.core.model.data.QRecord()));
|
||||
assertThatThrownBy(() -> new com.kingsrook.qqq.backend.core.actions.tables.InsertAction().execute(insert))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the actual service and its caller's record permissions.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry upload() throws Exception
|
||||
{
|
||||
return (service.upload("document", 1, "hello.txt", "text/plain", new ByteArrayInputStream(new byte[] { 1, 2, 3 }), null));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Independent JDBC observation, outside the service transaction.
|
||||
*******************************************************************************/
|
||||
private int rowCount() throws Exception
|
||||
{
|
||||
try(Connection connection = connect(); var sql = connection.createStatement();
|
||||
var rows = sql.executeQuery("SELECT COUNT(*) FROM collaboration_attachment"))
|
||||
{
|
||||
rows.next();
|
||||
return (rows.getInt(1));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Connect only to this disposable fixture's mapped port.
|
||||
*******************************************************************************/
|
||||
private static Connection connect() throws Exception
|
||||
{
|
||||
return (DriverManager.getConnection("jdbc:postgresql://" + postgres.getHost() + ":"
|
||||
+ postgres.getMappedPort(5432) + "/postgres", "postgres", "attachment-fixture-only"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve bytes and inject provider failure independently of PostgreSQL.
|
||||
*******************************************************************************/
|
||||
private static class RecordingStorage extends AttachmentStorage
|
||||
{
|
||||
private final Map<String, byte[]> objects = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private boolean failDelete;
|
||||
private boolean failPutAfterWrite;
|
||||
private final java.util.Set<String> failedKeys = new java.util.HashSet<>();
|
||||
private final java.util.concurrent.atomic.AtomicInteger deleteAttempts = new java.util.concurrent.atomic.AtomicInteger();
|
||||
private java.util.concurrent.CountDownLatch concurrentDeletes;
|
||||
|
||||
/***************************************************************************
|
||||
** Keep the production service's upload bound active.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public long maxBytes(String name)
|
||||
{
|
||||
return (1024L);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Consume the same upload stream a real provider receives.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void put(String name, PutRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
objects.put(name + "/" + request.getKey(), request.getStream().readAllBytes());
|
||||
if(failPutAfterWrite)
|
||||
{
|
||||
throw (new IllegalStateException("injected upload acknowledgement failure"));
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Simulate a provider refusing deletion without losing stored bytes.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void delete(String name, String key)
|
||||
{
|
||||
deleteAttempts.incrementAndGet();
|
||||
if(concurrentDeletes != null)
|
||||
{
|
||||
concurrentDeletes.countDown();
|
||||
try
|
||||
{
|
||||
if(!concurrentDeletes.await(10, java.util.concurrent.TimeUnit.SECONDS))
|
||||
{
|
||||
throw (new IllegalStateException("Concurrent cleanup worker did not arrive"));
|
||||
}
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
if(failDelete || failedKeys.contains(key))
|
||||
{
|
||||
throw (new IllegalStateException("injected storage delete failure"));
|
||||
}
|
||||
objects.remove(name + "/" + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -383,7 +383,7 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Storage failures leave rows for retries; trusted cleanup works after parent deletion.
|
||||
** Storage failures leave durable cleanup; trusted cleanup works after parent deletion.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failuresAndCleanup() throws Exception
|
||||
@@ -391,8 +391,13 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
AttachmentEntry row = upload("file.txt", "x");
|
||||
storage.failDelete = true;
|
||||
assertThatThrownBy(() -> service.delete(List.of(row.id()))).hasMessage("Storage unavailable");
|
||||
assertThat(service.list("document", 1)).hasSize(1);
|
||||
assertThat(service.list("document", 1)).isEmpty();
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
storage.failDelete = false;
|
||||
admin();
|
||||
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isEqualTo(1);
|
||||
login("alice");
|
||||
upload("parent-cleanup.txt", "parent bytes");
|
||||
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
|
||||
assertThatThrownBy(() -> service.list("document", 1)).hasMessage("Not found");
|
||||
QContext.getQSession().getPermissions().clear();
|
||||
@@ -400,6 +405,8 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
cleanup.onParentsDeleted("document", List.of());
|
||||
cleanup.onParentsDeleted("document", List.of(1));
|
||||
cleanup.onParentsDeleted("document", List.of(1));
|
||||
admin();
|
||||
new AttachmentCleanupQueue(storage).retryPending();
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
|
||||
}
|
||||
|
||||
@@ -310,6 +310,9 @@ public class CollaborationDevServer extends AbstractQQQApplication
|
||||
{
|
||||
return (new QTableMetaData().withName(name).withLabel(label).withIcon(new QIcon(icon)).withBackendName("memory")
|
||||
.withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME)
|
||||
.withBehavior(com.kingsrook.qqq.backend.core.model.metadata.fields.DynamicDefaultValueBehavior.MODIFY_DATE))
|
||||
.withSection(new QFieldSection("audit", new QIcon("history"), Tier.T3, List.of("modifyDate")).withLabel("Updated"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)));
|
||||
}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.devserver;
|
||||
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceScopeProvider;
|
||||
import com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig;
|
||||
import com.kingsrook.qbits.redis.RedisConnectionConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitProducer;
|
||||
import com.kingsrook.qbits.redis.RedisRealtimeRuntimeService;
|
||||
import com.kingsrook.qbits.redis.RedisRuntimeService;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
|
||||
/*******************************************************************************
|
||||
** Loopback-only browser acceptance node. Run separate JVMs against one disposable
|
||||
** Redis endpoint. Record data is independently seeded, not a shared database.
|
||||
*******************************************************************************/
|
||||
public class SharedPresenceDevServer
|
||||
{
|
||||
/***************************************************************************
|
||||
** Arguments are HTTP port and disposable local Redis port. Never deploy this
|
||||
** fixture: it uses the demo's mock authentication and plaintext loopback Redis.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
if(args.length != 2)
|
||||
{
|
||||
throw (new IllegalArgumentException("Expected HTTP port and local Redis port"));
|
||||
}
|
||||
int port = Integer.parseInt(args[0]);
|
||||
int redisPort = Integer.parseInt(args[1]);
|
||||
var instance = CollaborationDevServer.buildInstance(port);
|
||||
CollaborationDevServer.seed(instance);
|
||||
instance.withInstanceDefaultAuthentication(new com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData()
|
||||
.withName("mock").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(AcceptanceSessions.class)));
|
||||
PresenceRegistry.close(instance);
|
||||
RealtimeBus.close(instance);
|
||||
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("presence",
|
||||
new RedisConnectionConfig().withHost("127.0.0.1").withPort(redisPort).withTls(false))
|
||||
.withRealtime("presence", "acceptance", "collaboration")).produce(instance);
|
||||
instance.getQBits().values().stream().map(bit -> bit.getConfig())
|
||||
.filter(CollaborationQBitConfig.class::isInstance).map(CollaborationQBitConfig.class::cast)
|
||||
.findFirst().orElseThrow().withRedisPresence(new RedisPresenceConfig().withConnectionName("presence")
|
||||
.withDeployment("acceptance").withApplication("collaboration")
|
||||
.withScopeProvider(new QCodeReference(SingleTenant.class)));
|
||||
var redis = new RedisRuntimeService();
|
||||
var transport = new RedisRealtimeRuntimeService();
|
||||
new com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider().defineAll(instance, "memory", null);
|
||||
var health = new com.kingsrook.qqq.backend.core.health.HealthRunner(instance, "acceptance-" + port, java.time.Clock.systemUTC());
|
||||
var server = CollaborationDevServer.server(instance, port, false);
|
||||
try
|
||||
{
|
||||
redis.start(instance);
|
||||
transport.start(instance);
|
||||
health.start();
|
||||
server.start();
|
||||
System.out.println("SHARED_PRESENCE_READY " + port);
|
||||
Thread.currentThread().join();
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop();
|
||||
health.stop();
|
||||
transport.stop();
|
||||
PresenceRegistry.close(instance);
|
||||
RealtimeBus.close(instance);
|
||||
redis.stop();
|
||||
redis.afterApplicationStop();
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit single-tenant fixture; no scope comes from browser request input.
|
||||
***************************************************************************/
|
||||
public static class SingleTenant implements PresenceScopeProvider
|
||||
{
|
||||
/************************************************************************
|
||||
** The two acceptance nodes share this configured tenant.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public String tenantScope()
|
||||
{
|
||||
return ("acceptance");
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Grant Redis dashboard access only to the fixture's existing administrator.
|
||||
***************************************************************************/
|
||||
public static class AcceptanceSessions extends CollaborationDevServer.DemoSessions
|
||||
{
|
||||
/************************************************************************
|
||||
** Keep normal demo identity/grants and add the service-view permission.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance,
|
||||
com.kingsrook.qqq.backend.core.model.session.QSession target, java.util.Map<String, Object> context)
|
||||
{
|
||||
super.customizeSession(instance, target, context);
|
||||
if("admin".equals(target.getUser().getIdReference()))
|
||||
{
|
||||
target.withPermissions("redisView.hasAccess", "health.read");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -200,7 +200,7 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
|
||||
|
||||
/*******************************************************************************
|
||||
** Each recipient gets their own message; the share and task kinds use their
|
||||
** own templates and key kinds; follow digests send no email.
|
||||
** own templates and key kinds, including follow digests.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void oneMessagePerRecipientAndKinds() throws Exception
|
||||
@@ -211,7 +211,7 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.TASK, "9:remindAt"));
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.FOLLOW, "1"));
|
||||
assertThat(messages()).extracting(m -> m.getValueString("idempotencyKey"))
|
||||
.containsExactlyInAnyOrder("collaboration:share:3:bob", "collaboration:share:3:eve", "collaboration:task:9:remindAt:bob");
|
||||
.containsExactlyInAnyOrder("collaboration:share:3:bob", "collaboration:share:3:eve", "collaboration:task:9:remindAt:bob", "collaboration:follow:1:bob");
|
||||
assertThat(messages()).allSatisfy(m -> assertThat(m.getValueString("toAddresses")).doesNotContain(","));
|
||||
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("collaboration:", 10)).extracting(EmailApiMessage::templateName)
|
||||
.contains("collaboration_share", "collaboration_task_reminder");
|
||||
@@ -228,7 +228,11 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
|
||||
withEmail();
|
||||
config.withRecipientEmailResolver(user -> "eve".equals(user) ? "other@example.org" : null);
|
||||
Notifications.send(notification("eve", CollaborationNotification.Category.MENTION, "1"));
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "2"));
|
||||
boolean[] receipt = {false};
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "2"), () -> receipt[0] = true);
|
||||
assertThat(receipt[0]).isFalse();
|
||||
Notifications.send(notification("eve", CollaborationNotification.Category.MENTION, "1"), () -> receipt[0] = true);
|
||||
assertThat(receipt[0]).isTrue();
|
||||
assertThat(messages()).singleElement().satisfies(m -> assertThat(m.getValueString("toAddresses")).contains("other@example.org"));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Configuration status="WARN">
|
||||
<Appenders>
|
||||
<Console name="Console" target="SYSTEM_ERR">
|
||||
<PatternLayout pattern="%level %logger{1}: %msg%n"/>
|
||||
</Console>
|
||||
</Appenders>
|
||||
<Loggers>
|
||||
<Root level="WARN"><AppenderRef ref="Console"/></Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
Reference in new issue
Block a user