Compare commits

...
Author SHA1 Message Date
james.maesandCodex 56a8b760d9 test(collaboration): capture multi-node presence capacity
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-10-29 09:56:38 -05:00
james.maesandGPT-6 347b4ff695 test: verify bounded Redis stall recovery
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 11:28:40 -05:00
james.maesandGPT-6 f38dc29f6f test: verify presence churn across surviving nodes
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 11:23:29 -05:00
james.maesandGPT-6 fd8d7afe76 test: measure cross-node browser presence updates
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 11:13:53 -05:00
james.maesandGPT-6 ea06d6fb4e test: measure presence resources and node loss
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 11:07:11 -05:00
james.maesandGPT-6 5b7c947480 test: add independent-node presence workload
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 10:33:49 -05:00
james.maesandGPT-6 ecff347efe build: adopt published virtual field provider core
Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-26 08:21:29 -05:00
james.maesandGPT-6 bdb1934c6a feat: expose tag labels and EAV values through core reads
Use batched providers without replacing host customizers. Preserve transaction visibility and parent authorization, retain tag picker behavior, and verify query, GET and export values. Requires coordinated core PR QRun-IO/qqq#1322; published dependency pin follows after its snapshot is available.

Refs QRun-IO/qqq#1196

Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-10-22 18:25:20 -05:00
james.maesandCodex c3182e2cbc docs: document published collaboration candidate and HA contract
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 19:29:02 -05:00
james.maesandCodex 8f348daf32 build: align collaboration with published core and Redis
Use canonical feature publication tag filter.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 19:11:33 -05:00
james.maesandCodex 284a7ac5b6 ci: enable coordinated feature snapshot publication
Use existing publish-tag support to produce isolated candidate coordinates without merging into develop. Setup configs validated; session-store moves to orb0.7.18 for QBit feature support. Exact prerequisite versions and artifact publication remain pending.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 16:35:06 -05:00
james.maesandCodex 221ec83457 feat: secure collaboration presence across shared Redis nodes
Add server-issued presence leases, scoped audience delivery, permission-aware projections and shared Redis integration through its public API. Include ordinary collaboration correctness fixes and HTTP, service, store and optional-consumer regression coverage. Local verify and live two-node browser/admin acceptance passed; coordinated publication and cold-consumer verification remain pending. Optional recovery and load research is preserved outside this release commit.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 16:06:13 -05:00
82 changed files with 7019 additions and 111 deletions

No files matched your search

+5 -3
View File
@@ -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.*/
+23
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+5 -5
View File
@@ -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.
+9
View File
@@ -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
+23 -31
View File
@@ -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
@@ -406,7 +408,7 @@ size, pending suggestions, and the five most-used approved tags.
`collaborationTags` is a virtual STRING field labeled Tags with possible-value source `collaboration_tag`. `TagCriteriaExpander` supports IN, NOT_IN, EQUALS, NOT_EQUALS, IS_BLANK, IS_NOT_BLANK and CONTAINS_ALL using core `QSubQuery` membership filters. Ordinary quick search matches labels; `#name` matches normalized names. Numeric criteria are tag IDs; string criteria are names. The old ID materializer is removed. Core owns SQL execution or bounded cross-backend materialization; its bound applies to inner rows, not final parent matches.
The runtime provider adds this field and its search entry where TAGS is enabled. It is queryable but not selectable: the overlay has no virtual-field post-query hook and existing host customizers must be preserved.
The runtime provider adds this field and its search entry where TAGS is enabled. With the coordinated core value-provider API, it is selectable in queries, record reads and exports. Labels are sorted, deduplicated and joined with a comma and space; the picker still uses tag IDs. The provider runs after the host post-query customizer without replacing it. This is an unreleased source change: see [virtual-field upgrade status](docs/VIRTUAL-FIELD-UPGRADE.md) before building or adopting this branch.
## UI API
@@ -681,8 +683,9 @@ metadata.
Memory uses core `JsonPathStorage`. PostgreSQL uses it after a cached
`SELECT qqq_custom_fields FROM <backend table> LIMIT 0` probe succeeds. Missing columns and
unsupported backends get read-only EAV virtual filters over `collaboration_field_value`, with
typed `QSubQuery` criteria. These fields are not query-selectable; EAV value writes and projection
are outside this version. `statusMessage` records the fallback reason. After adding a missing
typed `QSubQuery` criteria. The coordinated value-provider API also makes existing EAV values
selectable in queries, record reads and exports, with their declared types and possible-value
labels. EAV writes remain unsupported. See [virtual-field upgrade status](docs/VIRTUAL-FIELD-UPGRADE.md). `statusMessage` records the fallback reason. After adding a missing
column, call `CustomFieldStorage.invalidate(instance, tableName)` and refresh the overlay.
### PostgreSQL setup and indexes
@@ -712,34 +715,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.
+41
View File
@@ -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.
+47
View File
@@ -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).
+56
View File
@@ -0,0 +1,56 @@
# Selectable collaboration fields: unreleased upgrade
Tracks [QQQ #1196](https://github.com/QRun-IO/qqq/issues/1196).
This source branch requires the new core virtual-field value-provider API. Its
default BOM pin is now the published
`4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT`. Core publisher job 9716
passed from commit `cc3981bb3c95e8f2dd7ef2a02e2233db7d0e2a79`; an independent
consumer with an initially empty Maven repository downloaded its BOM and core
and compiled the provider interface on October 26, 2026. Both artifacts resolve
to timestamp `20261026.130631-1` from Sonatype's snapshot repository.
Published artifact SHA-256 receipts:
| Artifact | SHA-256 |
| --- | --- |
| BOM POM | `d68cf837d7e07513c4e70977829f99e6236cb3e89f8af85fc75239d5b52564a5` |
| Core POM | `d89d491aaa9312051ec462e1cf165b948ee3e88c9aec2721ff25a00e956c431b` |
| Core JAR | `ae6dbeba8d0e4bc55f2714303ee3482dbf6a0cd34c03663c6009d8fe2c1da54d` |
This collaboration follow-up is still unpublished. The coordinated versions in
CANDIDATE-RELEASE.md describe the earlier candidate, not this source branch.
Earlier local verification used core artifacts installed as
`4.1.0-values1196-SNAPSHOT`, with
`./mvnw.cmd -Dqqq.snapshot.version=4.1.0-values1196-SNAPSHOT verify`.
That version exists only in the local Maven repository; it is not an adoption pin.
## Behavior
- `collaborationTags` returns sorted, distinct approved labels from global or
matching-table vocabulary. Merged tags and deleted assignments are excluded.
The picker and filters retain their tag IDs; exports contain one Tags column.
- EAV custom fields return typed existing values and ordinary possible-value
display labels. Missing values are null internally and omitted from HTTP value
maps. This change does not enable EAV writes.
- Providers process batches and use the originating transaction. Parent record
authorization and field visibility remain enforced by core. Existing host
post-query customizers remain installed.
- SQL DISTINCT on provider values is explicitly unsupported. Backend primary-key
identity is required; joined heavy primary keys must be fetched explicitly.
## Release sequence
1. Publish and verify the coordinated core artifact containing the provider API (done).
2. Replace this branch's core BOM pin with that published version (done).
3. Run full collaboration verification against the published prerequisite
(done October 26: 198 tests, no failures/errors/skips; packaging and coverage
passed, including HTTP, PostgreSQL transaction and Redis tests).
4. Publish the collaboration snapshot and verify an independent consumer using
published artifacts. Update CANDIDATE-RELEASE.md with the exact pair.
5. Update Kof22 Website-Backend adoption work and qualify it in the selected
isolated staging environment before requesting production deployment.
Local verification is evidence for this implementation, not proof that the
published candidate or Kof22 deployment has these changes.
+264
View File
@@ -0,0 +1,264 @@
# Independent-node presence workload
This fixture targets QQQ #1302/#1291. It runs 2–8 independent JVM HTTP servers
against an owned loopback Redis container. Every node serves the same configured
mock identities across 2–8 tenant scopes. The scope comes from the authenticated
fixture identity, never a supplied tenant parameter. Records are independently
seeded in memory; this does not qualify shared database or production IAM behavior.
Build with Java 21:
```powershell
./mvnw.cmd test-compile dependency:build-classpath '-Dmdep.includeScope=test' '-Dmdep.outputFile=target/capacity-classpath.txt'
python docs/performance/presence_nodes.py --java /path/to/java --nodes 3 --tenants 2 --users 6 --tabs 4 --rounds 100
```
Four tabs per user means two duplicate tabs on each of two records. Every round
renews each issued lease on a rotating node, with sequential per-tab sequences.
All nodes must then return exactly the expected tenant's distinct people and
exactly two editors on both records. Any mismatch or request failure fails the
run. Requests use bounded worker concurrency and ten-second timeouts. Each node
has eight pooled Redis connections, a 500 ms pool wait and a 384 MiB heap ceiling.
Results and per-node logs are retained under `target/capacity-nodes-<id>`.
The result records parameters, node PIDs, elapsed time, sample counts and
nearest-rank HTTP p50/p95/p99/max. Renewal throughput includes snapshot checks
and is not maximum server throughput. All owned JVMs and the Redis container are
removed in `finally`; the receipt reports cleanup separately from test success.
Each fixture JVM emits resource samples every 100 ms using Java management APIs
and the Redis QBit's existing pool metrics. The receipt summarizes sampled heap
used/committed bytes, active/idle/waiting pool counts and the configured maximum.
CPU core equivalents are process CPU time divided by observed wall time; they
can exceed one for a multithreaded JVM. Observation begins after server startup
and includes lease setup and workload execution. Unsupported CPU counters are
reported as null. Linux fixtures also read process `VmRSS` from `/proc/self/status`
every 100 ms and record `residentBytes_sampled_max`; unsupported platforms report
null. Linux-container runs require resident-memory samples. Heap is not total
resident memory, and process RSS is not the container's complete cgroup footprint.
Sampled peaks may miss
short bursts and must not be represented as exact maxima. Raw samples remain in
node logs; missing node samples or a sampled pool-bound violation fail the run.
The final Redis INFO capture records `used_memory`, lifetime `used_memory_peak`,
and current `used_memory_rss`, plus process user/system CPU seconds since Redis
startup. Redis RSS is an end-of-run observation, not a sampled peak. These fresh,
owned Redis instances include fixture setup and diagnostics in their lifetime;
do not interpret those CPU values as workload-only measurements or add independent
process peaks and call the sum a measured simultaneous maximum. Missing Redis
memory/CPU diagnostics fail acceptance while cleanup still runs.
The [100-user resource run](results/linux-100-users-resources.json) used three
Linux JVMs, two tenants with 50 people each, four leases per person, 16 request
workers and 200 rounds. All 80,000 renewals and 2,400 exact-membership snapshots
passed in 102.568 workload seconds. Renewal p50/p95/p99/max were
11.326/21.765/25.881/1,007.404 ms; snapshot p99 was 19.424 ms. The one-second
renewal maximum is retained, not discarded. Cleanup passed.
Sampled JVM RSS peaks were 662,786,048 / 662,188,032 / 703,479,808 bytes
(approximately 632–671 MiB), while used-heap peaks were about 171–177 MiB.
CPU observations were 1.73–1.83 core equivalents per JVM; sampled pool-active
peaks were 7/7/6 of eight with no sampled waiters. Redis peak allocated memory
was 2,579,896 bytes; final RSS was 8,777,728 bytes. Redis process CPU since
startup was 23.738 system plus 35.526 user seconds. These measurements include
fixture overhead, omit browser load, and do not establish deployment limits.
Initial smoke run: three JVMs, two tenants, six users each, four tabs per user,
three rounds: 144 renewals and 36 snapshots passed with tenant isolation,
duplicate-tab deduplication and two editors retained. Cleanup passed. This short
run validates the harness, not sustained capacity or operational thresholds.
A second run with 12 users per tenant and 100 rounds passed 9,600 renewals and
1,200 snapshots in 23.110 seconds. Renewal p50/p95/p99 were 7.246/16.454/21.853 ms;
snapshot p50/p95/p99 were 12.908/15.776/18.239 ms. Every round checked exact
tenant membership, deduplication and two editors on both records at every node.
Cleanup passed. These are measurements from one local Windows/Docker run;
they do not establish deployment limits or browser propagation latency.
## Sustained-run limitation under investigation
The 1,000-round workload has intermittently failed with HTTP 503 on lease
renewal. Four runs failed; one completed 96,000 renewals and 12,000 snapshots.
The passing run does not resolve the failures. Temporary local diagnostics on
the third failure identified `JedisConnectionException` caused by
`SocketTimeoutException` while reading the `EVALSHA` response in
`RedisPresenceStore.mutate`. The configured socket timeout remained two seconds.
The temporary production-file diagnostics were removed after identifying this
path. No timeout increase, hidden request retry or production fix is included.
Resource samples did not show pool waiters, but cannot rule out short bursts.
Redis slow-command timings and latency events are now captured before fixture
cleanup, alongside Redis CPU, memory and command counters, to distinguish server
execution cost from transport or scheduling delays. Slow-command summaries omit
command arguments. These diagnostics belong only to the disposable fixture.
Until the cause is established and qualified, this workload establishes no
supported sustained throughput or production alert thresholds.
The timing-instrumented Windows-JVM run failed at round 480, with no commands
at or above the configured one-millisecond Redis slow-log threshold and no
latency-monitor events. Redis reported about 93 microseconds per `EVALSHA` on
average. This weakens a slow-Lua explanation but does not identify the delayed
component or exclude time spent outside command execution.
## Linux comparison
`--node-image eclipse-temurin:21-jre` optionally runs each JVM in its own Linux
container on an owned Docker network shared with Redis. The HTTP driver remains
on the host. Only loopback HTTP ports are published. Compiled fixture classes
and copied dependency JARs are mounted read-only; Redis remains disposable.
The existing loopback defaults remain unchanged when this option is absent.
Container mode records Docker client PIDs separately from container identities.
It removes the Windows-JVM-to-published-Redis-port path, but also changes the JVM
platform and scheduling environment; it is a diagnostic comparison, not proof
of a specific operating-system or Docker defect. Cleanup includes every owned
node container and the owned network.
`--churn-every 20` departs all tabs belonging to the last user in each tenant
every 20 rounds. Every departed lease must reject a later renewal with HTTP 409
and `PRESENCE_LEASE_EXPIRED` on another node. All surviving nodes must show that
user absent on both records, then show the original exact membership after
fresh, distinct leases are acquired and activated. Only this explicit rejoin
path acquires new leases; the node-crash path preserves existing ones.
`--pause-redis` adds a controlled failure phase after the ordinary rounds. It
freezes only the owned Redis container, sends up to 64 concurrent renewal
requests, and requires HTTP 503 within an eight-second fixture bound. Snapshots
must also fail explicitly rather than return an empty room. Redis is unpaused
in `finally`; once reachable, higher-sequence renewals must recover the same
leases and exact membership. The test requires observed pool waiters and rejects
sampled active counts above the configured maximum. It does not retry failed
requests invisibly or raise production timeouts.
The [Linux Redis-stall receipt](results/linux-redis-stall.json) passed with three
nodes, two tenants, 24 users and 96 tab leases. All 96 stalled renewals and all
three node snapshots returned the expected unavailable response. Existing
leases recovered after Redis resumed, and cleanup passed. This proves bounded
failure and recovery for an injected stall; it does not explain the intermittent
Windows timeout or establish a saturation throughput threshold.
```powershell
python docs/performance/presence_nodes.py --java java --node-image eclipse-temurin:21-jre --nodes 3 --tenants 2 --users 12 --tabs 4 --rounds 1000
```
The first Linux comparison completed 1,000 rounds in 250.811 seconds: 96,000
renewals and 12,000 snapshots, with exact membership/editor assertions and
cleanup passing. Renewal p95/p99 were 15.331/17.760 ms. The sampled active pool
peak was five of eight, with no sampled waiters. This one run is separate from
the unresolved Windows failures and does not establish a production SLA.
`--crash-round 50` abruptly kills the first node before that round, then sends
all existing leases through surviving nodes. It does not acquire replacement
leases or hide failed requests with retries. Exact tenant membership, duplicate
tabs and editor counts must remain correct on every surviving node. This tests
HTTP lease continuity after process loss, not browser/SSE reconnect, ingress
failover, database durability or a Redis outage.
The Linux crash run passed 100 rounds (9,600 renewals and 996 snapshots) in
25.148 seconds. The first JVM exited with code 137 at round 50; the remaining
two retained all issued leases and expected membership. Cleanup passed.
A [combined churn and node-loss run](results/linux-node-crash-churn.json) passed
100 rounds with the first node killed at round 50 and churn every 20 rounds.
Five cycles rejected all 40 departed handles and checked absence/restored
membership across both tenants and records on every surviving node. Cleanup
passed. This is bounded HTTP lifecycle qualification; browser reconnect and
missed-event behavior remain separate requirements.
Recorded receipts retain both successful and failing evidence:
- [Windows timeout with Redis diagnostics](results/windows-timeout.json)
- [Linux 1,000-round run](results/linux-1000-rounds.json)
- [Linux node crash](results/linux-node-crash.json)
Only the Java executable path is normalized in these receipts. The Linux image
used for these runs was
`eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99`.
## Browser propagation baseline
`browser_subscription_affinity.mjs FRONTEND_REPO BASE_URL RESULT_PATH` verifies
the current owning-node subscription contract against the two-node fixture. It
uses the connection ID from the real browser's successful subscription request,
then preserves the same session cookies while changing only the test routing
cookie. The [published-artifact receipt](results/browser-subscription-affinity.json)
records owner 200, other node 404, owner 200, with the requested channel active
on both successful responses and the unsaved draft retained. This proves why
subscription requests need owning-node routing even when presence state is shared.
It does not test the deployed Traefik cookie, HTTPS, buffering or failover.
The packaged runner accepts this script through `--affinity-script`.
`browser_presence_latency.mjs` measures Chromium field-focus changes until a
different editor sees the corresponding remote marker. Supply a caller-owned
two-node loopback fixture serving the published frontend with `alice`/`admin`
mock identities and the acceptance router's `presence_node=first/second`
affinity cookies:
```powershell
node docs/performance/browser_presence_latency.mjs /path/to/frontend http://localhost:PORT result.json 25
```
Use `presence_router.mjs FIRST_NODE_PORT SECOND_NODE_PORT ROUTER_PORT` for this
test. It binds only to loopback and adds a test-only POST endpoint that severs
first-node SSE responses. Never deploy this fault-injection router. The packaged
acceptance fixture accepts it through `--router-script` alongside `--latency-script`.
The test verifies the target marker is absent before every change, the old
marker disappears afterward, both real SSE streams opened successfully,
issued-lease writes reached distinct nodes, and unsaved drafts survive. API
responses are not mocked. The fixture owns backend/router/Redis cleanup; this
script owns and closes its browser.
After timing the normal transitions, the script takes the observer offline and
explicitly severs its existing SSE response through the fixture router. It requires
an observed stream failure, a failed offline fetch, three acknowledged sender focus
changes, and absence of the new marker while disconnected. After network restoration
it requires a new successful SSE response and the current marker with the previous
marker removed, without reload or loss of either unsaved draft. Recovery may use
the shipped reconnect and polling behavior; this does not prove event replay.
The [disconnect/recovery receipt](results/browser-reconnect.json) passed against
the public RC37 JAR: one stream severed, three missed changes, recovery in 1,858 ms,
both drafts preserved. The accompanying six-user and Redis admin checks passed;
fixture cleanup passed. This is one Chromium disconnect case without concurrent
load, not a reconnect storm, lease-expiry test, or production recovery objective.
Append `load` to the browser command (or use `--latency-load` on the packaged
fixture) to add 16 concurrent HTTP lease workers: four mock identities with four
leases each on the same record, split between both nodes. Each worker renews
sequentially with a 100 ms pause after each response. The browser observations
are spread over at least 60 seconds; every observation interval must include
completed background renewals. The workload continues during disconnect/reconnect.
Any request or lease-cleanup failure fails the test. Responses are disposed after
reading, and workers stop before the contexts close. The receipt reports completed
renewals, observed node counts, elapsed time and any errors. These are HTTP load
workers, not 16 additional browser tabs or distinct people.
The [bounded load/reconnect receipt](results/browser-load-reconnect.json) passed
9,046 renewals over 69.728 seconds, with no worker or lease-release errors.
Across 25 observed focus transitions, p50/p95/p99 were 32.384/85.391/785.495 ms;
p99 is the maximum of this small sample, not a reliable tail-latency estimate.
The observer recovered in 1,855.887 ms after missing three updates while load
continued. Both drafts survived. Six-user and Redis admin acceptance also passed
against the public RC37 JAR; fixture cleanup passed. An initial run failed only
because the new load helper used the wrong lease-release endpoint; that receipt
is retained in the local packaged fixture history, not counted as a passing run.
The [25-transition receipt](results/browser-focus-25.json) passed against the
downloaded public RC37 JAR and published backend snapshots. Focus-to-observed-DOM
p50/p95/p99 were 31.648/33.078/36.092 ms; p99 is the maximum in this small sample.
There were no page errors or failed lease responses. Six-user presence and Redis
admin browser checks also passed in the same fixture run; fixture cleanup passed.
This baseline includes browser automation, UI debounce, network, invalidation
and rendering. It is not a server-only transport measurement, an SSE-only causal
proof, an under-load result, or a production latency objective. The separate load
receipt above covers one bounded workload and disconnect scenario; larger loads,
reconnect storms and deployment-specific behavior remain unqualified.
Remaining qualification includes longer soak and larger workloads, container
resource limits, reconnect storms and the actual deployment's capacity and alert
thresholds. The receipts above cover specific bounded HTTP, browser, saturation,
churn, missed-update and process-crash cases, not every combination. The native
Windows intermittent Redis read timeout remains unresolved. Loopback HTTP latency must not be reported
as browser propagation or a production SLA. No deployed capacity threshold is
established by this fixture.
@@ -0,0 +1,145 @@
// Real UI observations against a caller-owned two-node acceptance fixture.
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';
import { setTimeout as delay } from 'node:timers/promises';
import { startPresenceLoad } from './browser_presence_load.mjs';
const [frontendRepo, base, output, countValue = '25', loadMode] = process.argv.slice(2);
if (loadMode && loadMode !== 'load') throw new Error('Optional final argument must be load');
const samples = Number(countValue);
if (!frontendRepo || !base || !output || !Number.isInteger(samples) || samples < 5 || samples > 100) {
throw new Error('Expected frontend repo, loopback base URL, result path and 5..100 samples');
}
if (!['localhost', '127.0.0.1'].includes(new URL(base).hostname)) {
throw new Error('Only the disposable loopback fixture is supported');
}
const require = createRequire(resolve(frontendRepo, 'package.json'));
const { chromium, expect } = require('@playwright/test');
const result = { status: 'FAIL', samples: [], streams: [], requests: [], errors: [],
scope: 'Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load' };
const browser = await chromium.launch({ headless: true });
let load;
try {
async function editor(user, node) {
const context = await browser.newContext();
await context.addCookies([
{ name: 'collab_dev_user', value: user, url: base },
{ name: 'presence_node', value: node, url: base },
]);
const page = await context.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', error => result.errors.push(error.message));
page.on('response', response => {
const path = new URL(response.url()).pathname;
if (path.endsWith('/realtime/stream')) {
result.streams.push({ user, status: response.status(), type: response.headers()['content-type'] });
}
if (path.includes('/presence/leases')) {
result.requests.push({ user, status: response.status(), node: response.headers()['x-acceptance-node'] });
}
});
await page.goto(`${base}/app/person/1/edit?createMockSession=true`);
await expect(page.locator('[data-qqq-id="firstName"]')).toBeVisible();
return page;
}
const observer = await editor('alice', 'first');
const sender = await editor('admin', 'second');
await observer.locator('[data-qqq-id="firstName"]').fill('Retained observer draft');
await sender.locator('[data-qqq-id="firstName"]').fill('Retained sender draft');
await expect(observer.locator('[data-presence-field="firstName"]')).toContainText('Demo Administrator');
if (loadMode) {
load = await startPresenceLoad(browser, base, expect);
result.load = load.state;
result.scope = 'Chromium focus-to-remote-DOM and reconnect under 16 concurrent HTTP lease workers; loopback, four background identities';
}
for (let index = 0; index < samples; index++) {
const previousCompleted = load?.state.completed;
if (load) await delay(60000 / samples);
const field = index % 2 === 0 ? 'lastName' : 'firstName';
const previous = field === 'firstName' ? 'lastName' : 'firstName';
await expect(observer.locator(`[data-presence-field="${field}"]`)).not.toContainText('Demo Administrator');
const before = performance.now();
await sender.locator(`[data-qqq-id="${field}"]`).focus();
await expect(observer.locator(`[data-presence-field="${field}"]`)).toContainText('Demo Administrator');
result.samples.push({ field, propagation_ms: performance.now() - before });
await expect(observer.locator(`[data-presence-field="${previous}"]`)).not.toContainText('Demo Administrator');
if (load) {
expect(load.state.errors).toEqual([]);
expect(load.state.completed).toBeGreaterThan(previousCompleted);
}
}
// Drop a real browser connection, miss multiple focus updates, then converge
// through the shipped reconnect/polling behavior without reloading the form.
const oldField = samples % 2 === 0 ? 'firstName' : 'lastName';
const newField = oldField === 'firstName' ? 'lastName' : 'firstName';
const streamsBefore = result.streams.filter(stream => stream.user === 'alice').length;
result.reconnect = { status: 'FAIL', missed_changes: 0 };
const interrupted = observer.waitForEvent('requestfailed', {
predicate: request => new URL(request.url()).pathname.endsWith('/realtime/stream'),
timeout: 10000,
});
await observer.context().setOffline(true);
const disconnect = await fetch(`${base}/__acceptance/disconnect-first`, { method: 'POST' });
expect(disconnect.status).toBe(200);
result.reconnect.disconnected_streams = (await disconnect.json()).disconnected;
expect(result.reconnect.disconnected_streams).toBeGreaterThan(0);
const failedStream = await interrupted;
result.reconnect.stream_failure = failedStream.failure()?.errorText;
const offlineFetch = await observer.evaluate(async () => {
try {
await fetch(`/app/?offlineProbe=${Date.now()}`, { cache: 'no-store' });
return false;
} catch { return true; }
});
expect(offlineFetch).toBe(true);
for (const field of [newField, oldField, newField]) {
const renewed = sender.waitForResponse(response =>
new URL(response.url()).pathname.endsWith('/presence/leases/renew') &&
response.request().method() === 'POST' && response.status() === 200 &&
response.request().postDataJSON()?.fieldName === field);
await sender.locator(`[data-qqq-id="${field}"]`).focus();
await renewed;
result.reconnect.missed_changes++;
}
await expect(observer.locator(`[data-presence-field="${newField}"]`)).not.toContainText('Demo Administrator');
const resumed = performance.now();
await observer.context().setOffline(false);
await expect.poll(() => result.streams.filter(stream => stream.user === 'alice' &&
stream.status === 200 && stream.type?.includes('text/event-stream')).length,
{ timeout: 30000 }).toBeGreaterThan(streamsBefore);
await expect(observer.locator(`[data-presence-field="${newField}"]`)).toContainText('Demo Administrator', { timeout: 30000 });
await expect(observer.locator(`[data-presence-field="${oldField}"]`)).not.toContainText('Demo Administrator');
result.reconnect.recovery_ms = performance.now() - resumed;
result.reconnect.status = 'PASS';
if (load) {
await load.stop();
expect(load.state.errors).toEqual([]);
expect(load.state.elapsed_ms).toBeGreaterThanOrEqual(60000);
expect(load.state.nodes.first).toBeGreaterThan(0);
expect(load.state.nodes.second).toBeGreaterThan(0);
load = undefined;
}
await expect(observer.locator('[data-qqq-id="firstName"]')).toHaveValue('Retained observer draft');
await expect(sender.locator('[data-qqq-id="firstName"]')).toHaveValue('Retained sender draft');
for (const user of ['alice', 'admin']) {
expect(result.streams.some(stream => stream.user === user && stream.status === 200 && stream.type?.includes('text/event-stream'))).toBe(true);
}
expect(result.requests.some(request => request.user === 'alice' && request.node === 'first')).toBe(true);
expect(result.requests.some(request => request.user === 'admin' && request.node === 'second')).toBe(true);
expect(result.requests.every(request => request.status === 200)).toBe(true);
expect(result.errors).toEqual([]);
const ordered = result.samples.map(sample => sample.propagation_ms).sort((a, b) => a - b);
result.percentiles_ms = Object.fromEntries([['p50', .5], ['p95', .95], ['p99', .99], ['max', 1]]
.map(([name, quantile]) => [name, ordered[Math.ceil(ordered.length * quantile) - 1]]));
result.status = 'PASS';
} catch (error) {
result.failure = String(error);
process.exitCode = 1;
} finally {
if (load) await load.stop();
await browser.close();
await writeFile(output, JSON.stringify(result, null, 2));
console.log(JSON.stringify(result, null, 2));
}
@@ -0,0 +1,75 @@
// Bounded real HTTP load using caller-owned authenticated browser contexts.
import { performance } from 'node:perf_hooks';
import { setTimeout as delay } from 'node:timers/promises';
export async function startPresenceLoad(browser, base, expect) {
const state = { workers: 16, completed: 0, errors: [], nodes: {}, elapsed_ms: 0 };
const contexts = [];
const leases = [];
let running = true;
let stopped = false;
let tasks = [];
const began = performance.now();
const endpoint = `${base}/qqq/v1/qbit/collaboration/presence/leases`;
async function post(context, path, data) {
const response = await context.request.post(endpoint + path, { data, timeout: 10000 });
try {
expect(response.status()).toBe(200);
const node = response.headers()['x-acceptance-node'];
state.nodes[node] = (state.nodes[node] ?? 0) + 1;
return await response.json();
} finally { await response.dispose(); }
}
async function stop() {
if (stopped) return;
stopped = true;
running = false;
await Promise.all(tasks);
state.elapsed_ms = performance.now() - began;
for (const lease of leases) {
try {
const response = await lease.context.request.delete(`${base}/qqq/v1/qbit/collaboration/presence`, {
data: { ...lease.target, connectionId: lease.id, sequence: ++lease.sequence }, timeout: 10000,
});
try { expect(response.status()).toBe(200); } finally { await response.dispose(); }
} catch (error) { state.errors.push(String(error)); }
}
for (const context of contexts) await context.close();
}
try {
for (const [index, user] of ['bob', 'carol', 'dave', 'erin'].entries()) {
const context = await browser.newContext();
contexts.push(context);
await context.addCookies([
{ name: 'collab_dev_user', value: user, url: base },
{ name: 'presence_node', value: index % 2 ? 'second' : 'first', url: base },
]);
const page = await context.newPage();
await page.goto(`${base}/app/person/1?createMockSession=true`);
await expect(page.locator('[data-qqq-id="record-view-title-person"]')).toBeVisible();
await page.close();
for (let tab = 0; tab < 4; tab++) {
const target = { table: 'person', pk: '1' };
const opened = await post(context, '', target);
expect(typeof opened.connectionId).toBe('string');
leases.push({ context, target, id: opened.connectionId, sequence: 0, completed: 0 });
}
}
tasks = leases.map(async lease => {
try {
while (running) {
await post(lease.context, '/renew', { ...lease.target, connectionId: lease.id,
sequence: ++lease.sequence, mode: 'VIEWING' });
lease.completed++;
state.completed++;
await delay(100);
}
} catch (error) { state.errors.push(String(error)); running = false; }
});
await expect.poll(() => leases.filter(lease => lease.completed > 0).length).toBe(16);
return { state, stop };
} catch (error) {
await stop();
throw error;
}
}
@@ -0,0 +1,60 @@
// Verify owning-node routing with real published UI and backend artifacts.
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
const [frontendRepo, base, output] = process.argv.slice(2);
if (!frontendRepo || !output || !['localhost', '127.0.0.1'].includes(new URL(base).hostname)) {
throw new Error('Expected frontend repo, owned loopback URL and result path');
}
const require = createRequire(resolve(frontendRepo, 'package.json'));
const { chromium, expect } = require('@playwright/test');
const result = { status: 'FAIL', checks: [], errors: [] };
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext();
await context.addCookies([
{ name: 'collab_dev_user', value: 'alice', url: base },
{ name: 'presence_node', value: 'first', url: base },
]);
const page = await context.newPage();
page.on('pageerror', error => result.errors.push(error.message));
const subscribed = page.waitForResponse(response =>
new URL(response.url()).pathname.endsWith('/realtime/subscriptions') && response.status() === 200);
await page.goto(`${base}/app/person/1/edit?createMockSession=true`);
await expect(page.locator('[data-qqq-id="firstName"]')).toBeVisible();
await page.locator('[data-qqq-id="firstName"]').fill('Keep this unsaved draft');
const response = await subscribed;
expect(response.headers()['x-acceptance-node']).toBe('first');
const { connectionId } = response.request().postDataJSON();
expect(typeof connectionId).toBe('string');
for (const [node, expected] of [['first', 200], ['second', 404], ['first', 200]]) {
// Keep the same session/user cookies and change only the test routing cookie.
const cookies = (await context.cookies(base)).map(cookie =>
`${cookie.name}=${cookie.name === 'presence_node' ? node : cookie.value}`).join('; ');
const changed = await context.request.post(`${base}/qqq/v1/realtime/subscriptions`, {
headers: { Cookie: cookies },
data: { connectionId, add: ['record:person:1'], remove: [] }, timeout: 10000,
});
try {
result.checks.push({ node: changed.headers()['x-acceptance-node'], status: changed.status() });
expect(changed.headers()['x-acceptance-node']).toBe(node);
expect(changed.status()).toBe(expected);
if (expected === 200) {
const body = await changed.json();
expect(body.active).toContain('record:person:1');
expect(body.refused ?? []).toEqual([]);
}
} finally { await changed.dispose(); }
}
await expect(page.locator('[data-qqq-id="firstName"]')).toHaveValue('Keep this unsaved draft');
expect(result.errors).toEqual([]);
result.status = 'PASS';
} catch (error) {
result.failure = String(error);
process.exitCode = 1;
} finally {
await browser.close();
await writeFile(output, JSON.stringify(result, null, 2));
console.log(JSON.stringify(result, null, 2));
}
+343
View File
@@ -0,0 +1,343 @@
"""Bounded HTTP presence workload across independent, disposable JVMs.
This measures loopback HTTP service latency, not browser propagation or a
production capacity limit. Run test-compile and dependency:build-classpath with
test scope and output target/capacity-classpath.txt before invoking this script.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor
import json
import math
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
def port():
"""Choose a loopback port; child startup detects a competing bind."""
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
return listener.getsockname()[1]
def request(node, user, path, body=None, tab=0, method=None, expected=200):
"""Use real HTTP and the explicit mock identity; no tenant parameter."""
separator = "&" if "?" in path else "?"
req = urllib.request.Request(
f"http://127.0.0.1:{node}/qqq/v1/qbit/collaboration{path}{separator}user={user}",
data=None if body is None else json.dumps(body).encode(),
method=method,
headers={"Content-Type": "application/json",
"Cookie": f"sessionUUID={user}-capacity-{tab}"})
try:
response = urllib.request.urlopen(req, timeout=10)
except urllib.error.HTTPError as error:
if error.code == expected:
with error:
return json.load(error)
with error:
detail = error.read(4096).decode(errors="replace")
raise RuntimeError(f"HTTP {error.code} node={node} user={user} path={path}: {detail}") from error
with response:
if response.status != expected:
raise AssertionError(f"Unexpected status {response.status}")
return json.load(response)
def distribution(values):
"""Nearest-rank milliseconds, with no discarded slow or failed samples."""
ordered = sorted(values)
return {"samples": len(ordered), **{
name: ordered[max(0, math.ceil(len(ordered) * fraction) - 1)]
for name, fraction in (("p50_ms", .5), ("p95_ms", .95), ("p99_ms", .99), ("max_ms", 1))}}
def main():
"""Own child JVMs/container and retain results even when a check fails."""
parser = argparse.ArgumentParser()
parser.add_argument("--java", required=True)
parser.add_argument("--node-image", help="Optional Linux Java21 image; isolate Redis traffic on an owned Docker network")
parser.add_argument("--nodes", type=int, default=3)
parser.add_argument("--tenants", type=int, default=2)
parser.add_argument("--users", type=int, default=6)
parser.add_argument("--tabs", type=int, default=4)
parser.add_argument("--rounds", type=int, default=10)
parser.add_argument("--workers", type=int, default=8)
parser.add_argument("--crash-round", type=int, default=0, help="Kill the first node before this round; zero disables")
parser.add_argument("--churn-every", type=int, default=0, help="Leave/rejoin the last user's tabs every N rounds; zero disables")
parser.add_argument("--pause-redis", action="store_true", help="After the workload, freeze owned Redis and verify bounded failures/recovery")
args = parser.parse_args()
for name, lower, upper in (("nodes", 2, 8), ("tenants", 2, 8), ("users", 2, 100),
("tabs", 2, 8), ("rounds", 1, 1000), ("workers", 1, 64)):
if not lower <= getattr(args, name) <= upper:
parser.error(f"{name} must be {lower}..{upper}")
if args.crash_round and not 2 <= args.crash_round <= args.rounds:
parser.error("crash-round must be zero or 2..rounds")
if args.churn_every and not 1 <= args.churn_every <= args.rounds:
parser.error("churn-every must be zero or 1..rounds")
repo = Path(__file__).resolve().parents[2]
output = repo / "target" / ("capacity-nodes-" + uuid.uuid4().hex[:10])
output.mkdir(parents=True)
classpath = os.pathsep.join([str(repo / "target/test-classes"), str(repo / "target/classes"),
(repo / "target/capacity-classpath.txt").read_text().strip()])
processes, logs = [], []
container = "qqq-capacity-" + uuid.uuid4().hex[:12]
network = container + "-net"
node_containers = []
network_created = False
result = {"status": "FAIL", "parameters": vars(args), "scope": "loopback HTTP, mock identities, memory records"}
try:
network_args = []
if args.node_image:
result["node_image_id"] = subprocess.check_output(
["docker", "image", "inspect", args.node_image, "--format", "{{.Id}}"], text=True).strip()
subprocess.run(["docker", "network", "create", network], check=True, capture_output=True)
network_created = True
network_args = ["--network", network]
dependencies = output / "dependencies"
dependencies.mkdir()
linux_classpath = ["/fixture/target/test-classes", "/fixture/target/classes"]
for index, dependency in enumerate((repo / "target/capacity-classpath.txt").read_text().strip().split(os.pathsep)):
destination = dependencies / f"{index}.jar"
shutil.copyfile(dependency, destination)
linux_classpath.append("/fixture/" + destination.relative_to(repo).as_posix())
classpath = ":".join(linux_classpath)
subprocess.run(["docker", "run", "-d", "--name", container, "-p", "127.0.0.1::6379",
*network_args,
"redis:7.4-alpine", "redis-server", "--slowlog-log-slower-than", "1000",
"--slowlog-max-len", "128", "--latency-monitor-threshold", "1"], check=True, capture_output=True)
redis_port = int(subprocess.check_output(["docker", "port", container, "6379"], text=True).strip().rsplit(":", 1)[1])
nodes = [port() for _ in range(args.nodes)]
if len(set(nodes)) != len(nodes):
raise RuntimeError("Port allocation collision; no nodes started")
for node in nodes:
argfile = output / f"node-{node}.args"
connection_properties = (f"-Dcollab.capacity.redisHost={container}\n-Dcollab.dev.bindAddress=0.0.0.0\n"
if args.node_image else "")
argfile.write_text(f'-Xmx384m\n-Dcollab.capacity.tenants={args.tenants}\n-Dcollab.capacity.users={args.users}\n'
+ connection_properties +
'-cp\n"' + classpath.replace("\\", "/") + '"\n'
'com.kingsrook.qbits.collaboration.devserver.CapacityPresenceDevServer\n'
+ (f'8080\n6379\n' if args.node_image else f'{node}\n{redis_port}\n'), encoding="utf-8")
log = (output / f"node-{node}.log").open("w")
logs.append(log)
command = [args.java, "@" + str(argfile)]
if args.node_image:
node_container = f"{container}-node-{node}"
node_containers.append(node_container)
command = ["docker", "run", "--rm", "--name", node_container, "--network", network,
"-p", f"127.0.0.1:{node}:8080", "--mount", f"type=bind,source={repo},target=/fixture,readonly",
args.node_image, "java", "@/fixture/" + argfile.relative_to(repo).as_posix()]
processes.append(subprocess.Popen(command, cwd=repo,
stdout=log, stderr=subprocess.STDOUT,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)))
deadline = time.monotonic() + 120
for node, process in zip(nodes, processes):
while "CAPACITY_PRESENCE_READY " not in (output / f"node-{node}.log").read_text(errors="replace"):
if process.poll() is not None or time.monotonic() > deadline:
raise RuntimeError(f"Node {node} failed startup; inspect {output}")
time.sleep(.2)
actors = []
for tenant in range(args.tenants):
for user in range(args.users):
for tab in range(args.tabs):
identity = f"load-t{tenant}-u{user}"
record = str(1 + tab % 2)
node = nodes[len(actors) % len(nodes)]
lease = request(node, identity, "/presence/leases", {"table": "person", "pk": record}, tab)["connectionId"]
actors.append((identity, record, tab, lease, user))
renewals, snapshots = [], []
def check_rooms(excluded=None):
"""Check every tenant/record on every surviving node, including absence."""
for tenant in range(args.tenants):
expected_users = {f"load-t{tenant}-u{user}" for user in range(args.users) if user != excluded}
for node in nodes:
for record in ("1", "2"):
before = time.perf_counter()
viewers = request(node, f"load-t{tenant}-u0", f"/presence?table=person&pk={record}")["viewers"]
snapshots.append((time.perf_counter() - before) * 1000)
actual = {viewer["userId"] for viewer in viewers}
if actual != expected_users or len(viewers) != len(expected_users):
raise AssertionError(f"Tenant isolation/deduplication mismatch: {actual} != {expected_users}")
if sum(viewer["mode"] == "EDITING" for viewer in viewers) != sum(user != excluded for user in range(2)):
raise AssertionError("Unexpected editor count")
def renew(item, sequence):
"""Keep each tab sequential while moving its writes between nodes."""
index, (identity, record, tab, lease, user) = item
before = time.perf_counter()
request(nodes[(index + sequence) % len(nodes)], identity, "/presence/leases/renew",
{"connectionId": lease, "table": "person", "pk": record,
"mode": "EDITING" if user < 2 else "VIEWING", "sequence": sequence}, tab)
return (time.perf_counter() - before) * 1000
started = time.perf_counter()
with ThreadPoolExecutor(max_workers=args.workers) as workers:
for sequence in range(1, args.rounds + 1):
result["current_round"] = sequence
if sequence == args.crash_round:
crashed = nodes.pop(0)
if args.node_image:
subprocess.run(["docker", "kill", node_containers[0]], check=True, capture_output=True)
else:
processes[0].kill()
processes[0].wait(timeout=10)
result["crash"] = {"round": sequence, "http_port": crashed,
"surviving_nodes": len(nodes), "process_exit": processes[0].returncode}
renewals.extend(workers.map(lambda item: renew(item, sequence), enumerate(actors)))
check_rooms()
if args.churn_every and sequence % args.churn_every == 0:
departing = [(index, actor) for index, actor in enumerate(actors) if actor[4] == args.users - 1]
for index, (identity, record, tab, lease, user) in departing:
request(nodes[index % len(nodes)], identity, "/presence",
{"connectionId": lease, "table": "person", "pk": record, "sequence": sequence + 1}, tab, method="DELETE")
response = request(nodes[(index + 1) % len(nodes)], identity, "/presence/leases/renew",
{"connectionId": lease, "table": "person", "pk": record,
"mode": "VIEWING", "sequence": sequence + 2}, tab, expected=409)
if response.get("code") != "PRESENCE_LEASE_EXPIRED":
raise AssertionError("Departed lease was not rejected as expired")
check_rooms(excluded=args.users - 1)
for index, (identity, record, tab, lease, user) in departing:
replacement = request(nodes[(index + 1) % len(nodes)], identity, "/presence/leases",
{"table": "person", "pk": record}, tab)["connectionId"]
if replacement == lease:
raise AssertionError("Rejoin reused departed authority")
actors[index] = (identity, record, tab, replacement, user)
renew((index, actors[index]), sequence)
check_rooms()
result["churn_cycles"] = result.get("churn_cycles", 0) + 1
result["departed_leases_rejected"] = result.get("departed_leases_rejected", 0) + len(departing)
result["completed_rounds"] = sequence
if args.pause_redis:
failures = []
def stalled_renew(item):
"""A frozen authority must fail explicitly, never acknowledge a write."""
index, (identity, record, tab, lease, user) = item
before = time.perf_counter()
response = request(nodes[index % len(nodes)], identity, "/presence/leases/renew",
{"connectionId": lease, "table": "person", "pk": record,
"mode": "EDITING" if user < 2 else "VIEWING", "sequence": args.rounds + 1},
tab, expected=503)
if "viewers" in response or "temporarily unavailable" not in response.get("message", ""):
raise AssertionError("Stalled authority did not return the expected unavailable response")
duration = (time.perf_counter() - before) * 1000
if duration > 8000:
raise AssertionError("Unavailable response exceeded the eight-second fixture bound")
return duration
subprocess.run(["docker", "pause", container], check=True, capture_output=True)
try:
with ThreadPoolExecutor(max_workers=64) as burst:
failures.extend(burst.map(stalled_renew, enumerate(actors)))
for node in nodes:
response = request(node, "load-t0-u0", "/presence?table=person&pk=1", expected=503)
if "viewers" in response:
raise AssertionError("Stalled authority was presented as a room snapshot")
finally:
subprocess.run(["docker", "unpause", container], check=True, capture_output=True)
subprocess.run(["docker", "exec", container, "redis-cli", "PING"], check=True, capture_output=True, timeout=10)
for item in enumerate(actors):
renew(item, args.rounds + 2)
check_rooms()
result["redis_stall"] = {"rejected_renewals": len(failures), "failed_snapshot_nodes": len(nodes),
"response_times": distribution(failures), "same_leases_recovered": True}
elapsed = time.perf_counter() - started
result.update(status="PASS", seconds=elapsed, renewals=distribution(renewals),
snapshots=distribution(snapshots), renewal_throughput=len(renewals) / elapsed,
node_pids=[process.pid for process in processes],
pid_kind="docker-client" if args.node_image else "JVM", node_containers=node_containers)
except Exception as error:
result["error"] = str(error)
raise
finally:
failure_active = sys.exc_info()[0] is not None
try:
slow = json.loads(subprocess.check_output(
["docker", "exec", container, "redis-cli", "--json", "SLOWLOG", "GET", "128"], text=True, timeout=10))
result["redis_slow_commands"] = [{"unix_time": entry[1], "duration_us": entry[2], "command": entry[3][0]}
for entry in slow]
result["redis_latency_events"] = json.loads(subprocess.check_output(
["docker", "exec", container, "redis-cli", "--json", "LATENCY", "LATEST"], text=True, timeout=10))
for section in ("cpu", "memory", "commandstats", "stats"):
value = subprocess.check_output(["docker", "exec", container, "redis-cli", "INFO", section], text=True, timeout=10)
(output / f"redis-{section}.txt").write_text(value)
parsed = dict(line.split(":", 1) for line in value.splitlines() if ":" in line and not line.startswith("#"))
if section == "memory":
result["redis_memory"] = {key: int(parsed[key]) for key in
("used_memory", "used_memory_peak", "used_memory_rss")}
if section == "cpu":
result["redis_cpu_seconds_since_start"] = {key: float(parsed[key]) for key in
("used_cpu_sys", "used_cpu_user")}
except (subprocess.SubprocessError, ValueError, KeyError) as error:
result["redis_diagnostic_error"] = type(error).__name__
node_cleanup = True
for node_container in node_containers:
removed = subprocess.run(["docker", "rm", "-f", node_container], capture_output=True)
if removed.returncode != 0:
remaining = subprocess.run(["docker", "ps", "-aq", "--filter", f"name=^{node_container}$"], capture_output=True, text=True)
node_cleanup = node_cleanup and remaining.returncode == 0 and not remaining.stdout.strip()
for process in processes:
if process.poll() is None:
process.terminate()
for process in processes:
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
for log in logs:
log.close()
resources = []
for node_log in sorted(output.glob("node-*.log")):
samples = [json.loads(line.split("CAPACITY_METRIC ", 1)[1])
for line in node_log.read_text(errors="replace").splitlines()
if line.startswith("CAPACITY_METRIC ")]
if samples:
first, last = samples[0], samples[-1]
wall_ms = last["uptimeMillis"] - first["uptimeMillis"]
cpu_ns = last["cpuNanos"] - first["cpuNanos"]
resources.append({"node_log": node_log.name, "samples": len(samples),
"observed_wall_ms": wall_ms,
"cpu_seconds": cpu_ns / 1e9 if first["cpuNanos"] >= 0 else None,
"cpu_core_equivalents": cpu_ns / (wall_ms * 1e6) if wall_ms > 0 and first["cpuNanos"] >= 0 else None,
**{key + "_sampled_max": max(sample[key] for sample in samples)
for key in ("heapUsedBytes", "heapCommittedBytes", "poolActive", "poolIdle", "poolWaiting", "poolMaximum")},
"residentBytes_sampled_max": max(sample["residentBytes"] for sample in samples)
if all(sample.get("residentBytes", -1) >= 0 for sample in samples) else None})
if any(sample["poolActive"] > sample["poolMaximum"] for sample in samples):
result.update(status="FAIL", error="Sampled pool active count exceeded configured maximum")
result["resources"] = resources
if result["status"] == "PASS" and args.node_image and any(node["residentBytes_sampled_max"] is None for node in resources):
result.update(status="FAIL", error="Linux resident-memory samples missing")
if result["status"] == "PASS" and any(key not in result for key in ("redis_memory", "redis_cpu_seconds_since_start")):
result.update(status="FAIL", error="Redis memory/CPU diagnostics missing")
if result["status"] == "PASS" and len(resources) != args.nodes:
result.update(status="FAIL", error="Missing resource samples for a node")
if result["status"] == "PASS" and args.pause_redis and not any(node["poolWaiting_sampled_max"] > 0 for node in resources):
result.update(status="FAIL", error="Redis stall did not demonstrate pool waiters")
cleanup = subprocess.run(["docker", "rm", "-f", container], capture_output=True)
network_cleanup = True
if network_created:
network_cleanup = subprocess.run(["docker", "network", "rm", network], capture_output=True).returncode == 0
result["cleanup"] = cleanup.returncode == 0 and node_cleanup and network_cleanup and all(process.poll() is not None for process in processes)
(output / "result.json").write_text(json.dumps(result, indent=2))
print(json.dumps(result, indent=2))
print(output)
if result["status"] == "PASS" and not result["cleanup"]:
raise RuntimeError("Workload passed but owned resource cleanup failed")
if result["status"] == "FAIL" and not failure_active:
raise RuntimeError(result.get("error", "Capacity resource validation failed"))
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
// Disposable loopback fixture only; never deploy this fault-injection router.
import http from 'node:http';
const [first, second, listen] = process.argv.slice(2).map(Number);
if (![first, second, listen].every(port => Number.isInteger(port) && port > 0 && port < 65536)) {
throw new Error('Expected first-node, second-node and router ports');
}
const firstStreams = new Set();
const server = http.createServer((request, response) => {
if (request.method === 'POST' && request.url === '/__acceptance/disconnect-first') {
const disconnected = firstStreams.size;
for (const stream of firstStreams) stream.destroy();
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ disconnected }));
return;
}
const isSecond = /(?:^|;\s*)presence_node=second(?:;|$)/.test(request.headers.cookie ?? '');
const port = isSecond ? second : first;
const upstream = http.request({
hostname: '127.0.0.1', port, path: request.url, method: request.method,
headers: { ...request.headers, host: `127.0.0.1:${port}` },
}, incoming => {
response.writeHead(incoming.statusCode, {
...incoming.headers, 'x-acceptance-node': isSecond ? 'second' : 'first',
});
if (!isSecond && incoming.headers['content-type']?.includes('text/event-stream')) {
firstStreams.add(response);
}
incoming.pipe(response);
});
upstream.on('error', () => {
if (!response.headersSent) response.writeHead(502);
response.end('Acceptance node unavailable');
});
response.on('close', () => {
firstStreams.delete(response);
upstream.destroy();
});
request.pipe(upstream);
});
server.listen(listen, '127.0.0.1', () => console.log(`PRESENCE_ROUTER_READY ${listen}`));
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close());
@@ -0,0 +1,285 @@
{
"status": "PASS",
"samples": [
{
"field": "lastName",
"propagation_ms": 36.092099999999846
},
{
"field": "firstName",
"propagation_ms": 32.51870000000008
},
{
"field": "lastName",
"propagation_ms": 31.758600000000115
},
{
"field": "firstName",
"propagation_ms": 31.090299999999843
},
{
"field": "lastName",
"propagation_ms": 31.64980000000014
},
{
"field": "firstName",
"propagation_ms": 31.648199999999633
},
{
"field": "lastName",
"propagation_ms": 31.395500000000084
},
{
"field": "firstName",
"propagation_ms": 32.36750000000029
},
{
"field": "lastName",
"propagation_ms": 31.601799999999912
},
{
"field": "firstName",
"propagation_ms": 31.90599999999995
},
{
"field": "lastName",
"propagation_ms": 31.44150000000036
},
{
"field": "firstName",
"propagation_ms": 31.186699999999746
},
{
"field": "lastName",
"propagation_ms": 31.115800000000036
},
{
"field": "firstName",
"propagation_ms": 32.539400000000114
},
{
"field": "lastName",
"propagation_ms": 31.188000000000102
},
{
"field": "firstName",
"propagation_ms": 33.021200000000135
},
{
"field": "lastName",
"propagation_ms": 33.07759999999962
},
{
"field": "firstName",
"propagation_ms": 32.7176000000004
},
{
"field": "lastName",
"propagation_ms": 31.457300000000032
},
{
"field": "firstName",
"propagation_ms": 32.920900000000074
},
{
"field": "lastName",
"propagation_ms": 31.89130000000023
},
{
"field": "firstName",
"propagation_ms": 30.847200000000157
},
{
"field": "lastName",
"propagation_ms": 30.158300000000054
},
{
"field": "firstName",
"propagation_ms": 31.48239999999987
},
{
"field": "lastName",
"propagation_ms": 31.337600000000293
}
],
"streams": [
{
"user": "alice",
"status": 200,
"type": "text/event-stream;charset=utf-8"
},
{
"user": "admin",
"status": 200,
"type": "text/event-stream;charset=utf-8"
}
],
"requests": [
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
}
],
"errors": [],
"scope": "Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load",
"percentiles_ms": {
"p50": 31.648199999999633,
"p95": 33.07759999999962,
"p99": 36.092099999999846,
"max": 36.092099999999846
},
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
"fixture_cleanup": true,
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records."
}
@@ -0,0 +1,357 @@
{
"status": "PASS",
"samples": [
{
"field": "lastName",
"propagation_ms": 36.560600000000704
},
{
"field": "firstName",
"propagation_ms": 33.44050000000061
},
{
"field": "lastName",
"propagation_ms": 32.50890000000072
},
{
"field": "firstName",
"propagation_ms": 785.4951000000001
},
{
"field": "lastName",
"propagation_ms": 85.39069999999992
},
{
"field": "firstName",
"propagation_ms": 30.589599999999336
},
{
"field": "lastName",
"propagation_ms": 32.756099999998696
},
{
"field": "firstName",
"propagation_ms": 38.17399999999907
},
{
"field": "lastName",
"propagation_ms": 30.243099999999686
},
{
"field": "firstName",
"propagation_ms": 29.600799999992887
},
{
"field": "lastName",
"propagation_ms": 29.57220000000234
},
{
"field": "firstName",
"propagation_ms": 29.197800000001735
},
{
"field": "lastName",
"propagation_ms": 33.05130000000645
},
{
"field": "firstName",
"propagation_ms": 32.38390000000072
},
{
"field": "lastName",
"propagation_ms": 31.52850000000035
},
{
"field": "firstName",
"propagation_ms": 34.66169999999693
},
{
"field": "lastName",
"propagation_ms": 31.258300000001327
},
{
"field": "firstName",
"propagation_ms": 44.19029999999475
},
{
"field": "lastName",
"propagation_ms": 36.56189999999333
},
{
"field": "firstName",
"propagation_ms": 30.240600000004633
},
{
"field": "lastName",
"propagation_ms": 29.79899999999907
},
{
"field": "firstName",
"propagation_ms": 29.621500000001106
},
{
"field": "lastName",
"propagation_ms": 31.66189999999915
},
{
"field": "firstName",
"propagation_ms": 29.198399999993853
},
{
"field": "lastName",
"propagation_ms": 32.931200000006356
}
],
"streams": [
{
"user": "alice",
"status": 200,
"type": "text/event-stream;charset=utf-8"
},
{
"user": "admin",
"status": 200,
"type": "text/event-stream;charset=utf-8"
},
{
"user": "alice",
"status": 200,
"type": "text/event-stream;charset=utf-8"
}
],
"requests": [
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
}
],
"errors": [],
"scope": "Chromium focus-to-remote-DOM and reconnect under 16 concurrent HTTP lease workers; loopback, four background identities",
"load": {
"workers": 16,
"completed": 9046,
"errors": [],
"nodes": {
"first": 4540,
"second": 4522
},
"elapsed_ms": 69728.3579
},
"reconnect": {
"status": "PASS",
"missed_changes": 3,
"disconnected_streams": 1,
"stream_failure": "net::ERR_INCOMPLETE_CHUNKED_ENCODING",
"recovery_ms": 1855.8868999999977
},
"percentiles_ms": {
"p50": 32.38390000000072,
"p95": 85.39069999999992,
"p99": 785.4951000000001,
"max": 785.4951000000001
},
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
"fixture_cleanup": true,
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
"fixture_checks": [
"browser-six-presence",
"browser-redis-admin",
"browser-presence-latency"
]
}
@@ -0,0 +1,317 @@
{
"status": "PASS",
"samples": [
{
"field": "lastName",
"propagation_ms": 37.25770000000011
},
{
"field": "firstName",
"propagation_ms": 32.14499999999998
},
{
"field": "lastName",
"propagation_ms": 32.40950000000021
},
{
"field": "firstName",
"propagation_ms": 33.52500000000009
},
{
"field": "lastName",
"propagation_ms": 31.228200000000015
},
{
"field": "firstName",
"propagation_ms": 30.430999999999585
},
{
"field": "lastName",
"propagation_ms": 31.493199999999888
},
{
"field": "firstName",
"propagation_ms": 33.14539999999988
},
{
"field": "lastName",
"propagation_ms": 32.11259999999993
},
{
"field": "firstName",
"propagation_ms": 33.23129999999992
},
{
"field": "lastName",
"propagation_ms": 31.06800000000021
},
{
"field": "firstName",
"propagation_ms": 30.109199999999873
},
{
"field": "lastName",
"propagation_ms": 31.534299999999803
},
{
"field": "firstName",
"propagation_ms": 33.770800000000236
},
{
"field": "lastName",
"propagation_ms": 31.654199999999946
},
{
"field": "firstName",
"propagation_ms": 33.31700000000001
},
{
"field": "lastName",
"propagation_ms": 33.108600000000024
},
{
"field": "firstName",
"propagation_ms": 32.27370000000019
},
{
"field": "lastName",
"propagation_ms": 31.452499999999873
},
{
"field": "firstName",
"propagation_ms": 31.86220000000003
},
{
"field": "lastName",
"propagation_ms": 31.734399999999823
},
{
"field": "firstName",
"propagation_ms": 32.04489999999987
},
{
"field": "lastName",
"propagation_ms": 31.051899999999932
},
{
"field": "firstName",
"propagation_ms": 30.876300000000356
},
{
"field": "lastName",
"propagation_ms": 31.56970000000001
}
],
"streams": [
{
"user": "alice",
"status": 200,
"type": "text/event-stream;charset=utf-8"
},
{
"user": "admin",
"status": 200,
"type": "text/event-stream;charset=utf-8"
},
{
"user": "alice",
"status": 200,
"type": "text/event-stream;charset=utf-8"
}
],
"requests": [
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "alice",
"status": 200,
"node": "first"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
},
{
"user": "admin",
"status": 200,
"node": "second"
}
],
"errors": [],
"scope": "Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load",
"reconnect": {
"status": "PASS",
"missed_changes": 3,
"disconnected_streams": 1,
"stream_failure": "net::ERR_INCOMPLETE_CHUNKED_ENCODING",
"recovery_ms": 1858.2972000000004
},
"percentiles_ms": {
"p50": 31.86220000000003,
"p95": 33.770800000000236,
"p99": 37.25770000000011,
"max": 37.25770000000011
},
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
"fixture_cleanup": true,
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
"fixture_checks": [
"browser-six-presence",
"browser-redis-admin",
"browser-presence-latency"
]
}
@@ -0,0 +1,14 @@
{
"status": "PASS",
"checks": [
{ "node": "first", "status": 200 },
{ "node": "second", "status": 404 },
{ "node": "first", "status": 200 }
],
"errors": [],
"fixture_cleanup": true,
"fixture_checks": ["browser-six-presence", "browser-redis-admin", "browser-subscription-affinity"],
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
"scope": "Same browser session and live SSE connection ID; change only test-router affinity. Not deployed Traefik qualification."
}
@@ -0,0 +1,181 @@
{
"status": "PASS",
"parameters": {
"java": "/path/to/java21",
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
"nodes": 3,
"tenants": 2,
"users": 50,
"tabs": 4,
"rounds": 200,
"workers": 16,
"crash_round": 0,
"churn_every": 0,
"pause_redis": false
},
"scope": "loopback HTTP, mock identities, memory records",
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
"current_round": 200,
"completed_rounds": 200,
"seconds": 102.56801009993069,
"renewals": {
"samples": 80000,
"p50_ms": 11.325599974952638,
"p95_ms": 21.764500066637993,
"p99_ms": 25.881099980324507,
"max_ms": 1007.4040000326931
},
"snapshots": {
"samples": 2400,
"p50_ms": 15.130399959161878,
"p95_ms": 16.613700077868998,
"p99_ms": 19.423600053414702,
"max_ms": 28.864299994893372
},
"renewal_throughput": 779.9702843221492,
"node_pids": [
105696,
4828,
117588
],
"pid_kind": "docker-client",
"node_containers": [
"qqq-capacity-f58f3ddcf5f3-node-59675",
"qqq-capacity-f58f3ddcf5f3-node-59676",
"qqq-capacity-f58f3ddcf5f3-node-59677"
],
"redis_slow_commands": [
{
"unix_time": 1793033347,
"duration_us": 1432,
"command": "EVALSHA"
},
{
"unix_time": 1793033347,
"duration_us": 1612,
"command": "EVALSHA"
},
{
"unix_time": 1793033340,
"duration_us": 1877,
"command": "EVALSHA"
},
{
"unix_time": 1793033340,
"duration_us": 1313,
"command": "ZRANGE"
},
{
"unix_time": 1793033336,
"duration_us": 1316,
"command": "EVALSHA"
},
{
"unix_time": 1793033305,
"duration_us": 1459,
"command": "EVALSHA"
},
{
"unix_time": 1793033300,
"duration_us": 1767,
"command": "EVALSHA"
},
{
"unix_time": 1793033290,
"duration_us": 1180,
"command": "EVALSHA"
},
{
"unix_time": 1793033283,
"duration_us": 1421,
"command": "EVALSHA"
},
{
"unix_time": 1793033260,
"duration_us": 1291,
"command": "EVALSHA"
},
{
"unix_time": 1793033260,
"duration_us": 1551,
"command": "EVALSHA"
},
{
"unix_time": 1793033260,
"duration_us": 1083,
"command": "ZRANGE"
},
{
"unix_time": 1793033255,
"duration_us": 1039,
"command": "EVALSHA"
},
{
"unix_time": 1793033254,
"duration_us": 1215,
"command": "EVALSHA"
}
],
"redis_latency_events": [
[
"command",
1793033347,
1,
1
]
],
"redis_cpu_seconds_since_start": {
"used_cpu_sys": 23.737706,
"used_cpu_user": 35.526214
},
"redis_memory": {
"used_memory": 2415952,
"used_memory_peak": 2579896,
"used_memory_rss": 8777728
},
"resources": [
{
"node_log": "node-59675.log",
"samples": 1112,
"observed_wall_ms": 111099,
"cpu_seconds": 203.14,
"cpu_core_equivalents": 1.828459302063925,
"heapUsedBytes_sampled_max": 181098896,
"heapCommittedBytes_sampled_max": 211812352,
"poolActive_sampled_max": 7,
"poolIdle_sampled_max": 7,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8,
"residentBytes_sampled_max": 662786048
},
{
"node_log": "node-59676.log",
"samples": 1145,
"observed_wall_ms": 114399,
"cpu_seconds": 200.44,
"cpu_core_equivalents": 1.7521132177728826,
"heapUsedBytes_sampled_max": 185167416,
"heapCommittedBytes_sampled_max": 219152384,
"poolActive_sampled_max": 7,
"poolIdle_sampled_max": 7,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8,
"residentBytes_sampled_max": 662188032
},
{
"node_log": "node-59677.log",
"samples": 1159,
"observed_wall_ms": 115800,
"cpu_seconds": 200.16,
"cpu_core_equivalents": 1.728497409326425,
"heapUsedBytes_sampled_max": 179378632,
"heapCommittedBytes_sampled_max": 210763776,
"poolActive_sampled_max": 6,
"poolIdle_sampled_max": 7,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8,
"residentBytes_sampled_max": 703479808
}
],
"cleanup": true
}
@@ -0,0 +1,101 @@
{
"status": "PASS",
"parameters": {
"java": "java (Java 21)",
"node_image": "eclipse-temurin:21-jre",
"nodes": 3,
"tenants": 2,
"users": 12,
"tabs": 4,
"rounds": 1000,
"workers": 8
},
"scope": "loopback HTTP, mock identities, memory records",
"current_round": 1000,
"completed_rounds": 1000,
"seconds": 250.81072249996942,
"renewals": {
"samples": 96000,
"p50_ms": 6.353199947625399,
"p95_ms": 15.330899972468615,
"p99_ms": 17.760399961844087,
"max_ms": 326.8311999272555
},
"snapshots": {
"samples": 12000,
"p50_ms": 13.105699908919632,
"p95_ms": 14.261199976317585,
"p99_ms": 16.519399941898882,
"max_ms": 915.3468000004068
},
"renewal_throughput": 382.7587554595546,
"node_pids": [
64000,
40240,
115400
],
"pid_kind": "docker-client",
"node_containers": [
"qqq-capacity-0a8b7956499d-node-64951",
"qqq-capacity-0a8b7956499d-node-64952",
"qqq-capacity-0a8b7956499d-node-64953"
],
"redis_slow_commands": [
{
"unix_time": 1793030517,
"duration_us": 1117,
"command": "EVALSHA"
}
],
"redis_latency_events": [
[
"command",
1793030517,
1,
1
]
],
"resources": [
{
"node_log": "node-64951.log",
"samples": 2580,
"observed_wall_ms": 257899,
"cpu_seconds": 165.55,
"cpu_core_equivalents": 0.6419179601316795,
"heapUsedBytes_sampled_max": 166667368,
"heapCommittedBytes_sampled_max": 188743680,
"poolActive_sampled_max": 4,
"poolIdle_sampled_max": 5,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
},
{
"node_log": "node-64952.log",
"samples": 2569,
"observed_wall_ms": 256799,
"cpu_seconds": 165.85,
"cpu_core_equivalents": 0.6458358482704372,
"heapUsedBytes_sampled_max": 169216944,
"heapCommittedBytes_sampled_max": 192937984,
"poolActive_sampled_max": 5,
"poolIdle_sampled_max": 4,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
},
{
"node_log": "node-64953.log",
"samples": 2608,
"observed_wall_ms": 260700,
"cpu_seconds": 164.72,
"cpu_core_equivalents": 0.631837360951285,
"heapUsedBytes_sampled_max": 166319008,
"heapCommittedBytes_sampled_max": 188743680,
"poolActive_sampled_max": 4,
"poolIdle_sampled_max": 5,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
}
],
"cleanup": true,
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
}
@@ -0,0 +1,117 @@
{
"status": "PASS",
"parameters": {
"java": "java (Java 21)",
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
"nodes": 3,
"tenants": 2,
"users": 12,
"tabs": 4,
"rounds": 100,
"workers": 8,
"crash_round": 50,
"churn_every": 20
},
"scope": "loopback HTTP, mock identities, memory records",
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
"current_round": 100,
"completed_rounds": 100,
"churn_cycles": 5,
"departed_leases_rejected": 40,
"crash": {
"round": 50,
"http_port": 52824,
"surviving_nodes": 2,
"process_exit": 137
},
"seconds": 28.090077199973166,
"renewals": {
"samples": 9600,
"p50_ms": 7.532799965701997,
"p95_ms": 17.53149996511638,
"p99_ms": 29.359900043345988,
"max_ms": 88.13189994543791
},
"snapshots": {
"samples": 1092,
"p50_ms": 13.582199928350747,
"p95_ms": 15.386099927127361,
"p99_ms": 18.502700026147068,
"max_ms": 22.144700051285326
},
"renewal_throughput": 341.75769371004685,
"node_pids": [
22844,
66652,
61920
],
"pid_kind": "docker-client",
"node_containers": [
"qqq-capacity-bb9ba87919d2-node-52824",
"qqq-capacity-bb9ba87919d2-node-52825",
"qqq-capacity-bb9ba87919d2-node-52827"
],
"redis_slow_commands": [
{
"unix_time": 1793031681,
"duration_us": 1206,
"command": "EVALSHA"
},
{
"unix_time": 1793031681,
"duration_us": 1147,
"command": "EVALSHA"
}
],
"redis_latency_events": [
[
"command",
1793031681,
1,
1
]
],
"resources": [
{
"node_log": "node-52824.log",
"samples": 212,
"observed_wall_ms": 21099,
"cpu_seconds": 29.48,
"cpu_core_equivalents": 1.3972226171856486,
"heapUsedBytes_sampled_max": 163641024,
"heapCommittedBytes_sampled_max": 188743680,
"poolActive_sampled_max": 3,
"poolIdle_sampled_max": 4,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
},
{
"node_log": "node-52825.log",
"samples": 354,
"observed_wall_ms": 35299,
"cpu_seconds": 43.09,
"cpu_core_equivalents": 1.22071446783195,
"heapUsedBytes_sampled_max": 168178592,
"heapCommittedBytes_sampled_max": 192937984,
"poolActive_sampled_max": 5,
"poolIdle_sampled_max": 5,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
},
{
"node_log": "node-52827.log",
"samples": 340,
"observed_wall_ms": 33900,
"cpu_seconds": 42.42,
"cpu_core_equivalents": 1.2513274336283187,
"heapUsedBytes_sampled_max": 166291160,
"heapCommittedBytes_sampled_max": 188743680,
"poolActive_sampled_max": 5,
"poolIdle_sampled_max": 5,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
}
],
"cleanup": true,
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
}
@@ -0,0 +1,96 @@
{
"status": "PASS",
"parameters": {
"java": "java (Java 21)",
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
"nodes": 3,
"tenants": 2,
"users": 12,
"tabs": 4,
"rounds": 100,
"workers": 8,
"crash_round": 50
},
"scope": "loopback HTTP, mock identities, memory records",
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
"current_round": 100,
"completed_rounds": 100,
"crash": {
"round": 50,
"http_port": 54039,
"surviving_nodes": 2,
"process_exit": 137
},
"seconds": 25.147547999978997,
"renewals": {
"samples": 9600,
"p50_ms": 7.125200005248189,
"p95_ms": 17.407900071702898,
"p99_ms": 27.217599912546575,
"max_ms": 74.53640003222972
},
"snapshots": {
"samples": 996,
"p50_ms": 13.538200058974326,
"p95_ms": 15.59239998459816,
"p99_ms": 20.58999997097999,
"max_ms": 315.0679999962449
},
"renewal_throughput": 381.7469599822622,
"node_pids": [
85832,
90280,
67764
],
"pid_kind": "docker-client",
"node_containers": [
"qqq-capacity-e599dca8e163-node-54039",
"qqq-capacity-e599dca8e163-node-54040",
"qqq-capacity-e599dca8e163-node-54041"
],
"redis_slow_commands": [],
"redis_latency_events": [],
"resources": [
{
"node_log": "node-54039.log",
"samples": 198,
"observed_wall_ms": 19699,
"cpu_seconds": 28.58,
"cpu_core_equivalents": 1.4508350677699375,
"heapUsedBytes_sampled_max": 163478640,
"heapCommittedBytes_sampled_max": 188743680,
"poolActive_sampled_max": 3,
"poolIdle_sampled_max": 4,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
},
{
"node_log": "node-54040.log",
"samples": 300,
"observed_wall_ms": 29900,
"cpu_seconds": 41.85,
"cpu_core_equivalents": 1.399665551839465,
"heapUsedBytes_sampled_max": 165177008,
"heapCommittedBytes_sampled_max": 188743680,
"poolActive_sampled_max": 4,
"poolIdle_sampled_max": 5,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
},
{
"node_log": "node-54041.log",
"samples": 331,
"observed_wall_ms": 33000,
"cpu_seconds": 41.52,
"cpu_core_equivalents": 1.2581818181818183,
"heapUsedBytes_sampled_max": 168546936,
"heapCommittedBytes_sampled_max": 192937984,
"poolActive_sampled_max": 4,
"poolIdle_sampled_max": 5,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
}
],
"cleanup": true,
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
}
@@ -0,0 +1,117 @@
{
"status": "PASS",
"parameters": {
"java": "java (Java 21)",
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
"nodes": 3,
"tenants": 2,
"users": 12,
"tabs": 4,
"rounds": 30,
"workers": 8,
"crash_round": 0,
"churn_every": 0,
"pause_redis": true
},
"scope": "loopback HTTP, mock identities, memory records",
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
"current_round": 30,
"completed_rounds": 30,
"redis_stall": {
"rejected_renewals": 96,
"failed_snapshot_nodes": 3,
"response_times": {
"samples": 96,
"p50_ms": 1013.8123999349773,
"p95_ms": 4014.4751999760047,
"p99_ms": 4015.4822999611497,
"max_ms": 4015.4822999611497
},
"same_leases_recovered": true
},
"seconds": 20.33888840000145,
"renewals": {
"samples": 2880,
"p50_ms": 8.835799992084503,
"p95_ms": 23.678299970924854,
"p99_ms": 35.42319999542087,
"max_ms": 102.84149996004999
},
"snapshots": {
"samples": 372,
"p50_ms": 13.98350007366389,
"p95_ms": 16.946100047789514,
"p99_ms": 20.020700059831142,
"max_ms": 20.943500101566315
},
"renewal_throughput": 141.6006589622565,
"node_pids": [
88080,
78036,
121352
],
"pid_kind": "docker-client",
"node_containers": [
"qqq-capacity-68ad627e7be5-node-58312",
"qqq-capacity-68ad627e7be5-node-58314",
"qqq-capacity-68ad627e7be5-node-58315"
],
"redis_slow_commands": [
{
"unix_time": 1793032016,
"duration_us": 2198,
"command": "EVALSHA"
}
],
"redis_latency_events": [
[
"command",
1793032016,
2,
2
]
],
"resources": [
{
"node_log": "node-58312.log",
"samples": 280,
"observed_wall_ms": 27899,
"cpu_seconds": 23.96,
"cpu_core_equivalents": 0.8588121438044375,
"heapUsedBytes_sampled_max": 164221400,
"heapCommittedBytes_sampled_max": 188743680,
"poolActive_sampled_max": 6,
"poolIdle_sampled_max": 5,
"poolWaiting_sampled_max": 15,
"poolMaximum_sampled_max": 8
},
{
"node_log": "node-58314.log",
"samples": 289,
"observed_wall_ms": 28800,
"cpu_seconds": 23.2,
"cpu_core_equivalents": 0.8055555555555556,
"heapUsedBytes_sampled_max": 162849016,
"heapCommittedBytes_sampled_max": 192937984,
"poolActive_sampled_max": 4,
"poolIdle_sampled_max": 3,
"poolWaiting_sampled_max": 14,
"poolMaximum_sampled_max": 8
},
{
"node_log": "node-58315.log",
"samples": 272,
"observed_wall_ms": 27100,
"cpu_seconds": 23.85,
"cpu_core_equivalents": 0.8800738007380073,
"heapUsedBytes_sampled_max": 165779696,
"heapCommittedBytes_sampled_max": 188743680,
"poolActive_sampled_max": 5,
"poolIdle_sampled_max": 4,
"poolWaiting_sampled_max": 14,
"poolMaximum_sampled_max": 8
}
],
"cleanup": true,
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
}
@@ -0,0 +1,61 @@
{
"status": "FAIL",
"parameters": {
"java": "java (Java 21)",
"nodes": 3,
"tenants": 2,
"users": 12,
"tabs": 4,
"rounds": 1000,
"workers": 8
},
"scope": "loopback HTTP, mock identities, memory records",
"current_round": 480,
"completed_rounds": 479,
"error": "HTTP 503 node=56028 user=load-t0-u5 path=/presence/leases/renew: {\"error\":\"Presence is temporarily unavailable. Please retry.\",\"code\":\"ERROR\",\"message\":\"Presence is temporarily unavailable. Please retry.\",\"traceId\":\"cea30b82-e82b-49ec-af8a-64d8b6bd5af6\"}",
"redis_slow_commands": [],
"redis_latency_events": [],
"resources": [
{
"node_log": "node-56027.log",
"samples": 1067,
"observed_wall_ms": 106600,
"cpu_seconds": 94.328125,
"cpu_core_equivalents": 0.8848792213883677,
"heapUsedBytes_sampled_max": 160559696,
"heapCommittedBytes_sampled_max": 178257920,
"poolActive_sampled_max": 5,
"poolIdle_sampled_max": 5,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
},
{
"node_log": "node-56028.log",
"samples": 1066,
"observed_wall_ms": 106501,
"cpu_seconds": 95.8125,
"cpu_core_equivalents": 0.8996394400052582,
"heapUsedBytes_sampled_max": 160251104,
"heapCommittedBytes_sampled_max": 178257920,
"poolActive_sampled_max": 5,
"poolIdle_sampled_max": 4,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
},
{
"node_log": "node-56029.log",
"samples": 1066,
"observed_wall_ms": 106500,
"cpu_seconds": 95.25,
"cpu_core_equivalents": 0.8943661971830986,
"heapUsedBytes_sampled_max": 157995216,
"heapCommittedBytes_sampled_max": 175112192,
"poolActive_sampled_max": 5,
"poolIdle_sampled_max": 4,
"poolWaiting_sampled_max": 0,
"poolMaximum_sampled_max": 8
}
],
"cleanup": true,
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
}
+22 -2
View File
@@ -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-virtual-field-values-1196-cc3981bb3-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();
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.collaboration;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
@@ -74,7 +75,15 @@ public final class CollaborationRecords
*******************************************************************************/
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(QContext.getQBackendTransaction());
return (query(table, filter, QContext.getQBackendTransaction()));
}
/*******************************************************************************
** Provider reads participate in the originating query's explicit transaction.
*******************************************************************************/
public static List<QRecord> query(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException
{
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(transaction);
input.setTableMetaData(serviceMetadata(table));
input.withPermissionBypass("Trusted collaboration service performs parent/ownership authorization.");
return (new QueryAction().execute(input).getRecords().stream().map(QRecord::new).toList());
@@ -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())));
}
/*******************************************************************************
@@ -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())));
}
}
@@ -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()))
@@ -129,10 +129,20 @@ 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"));
}
if(transaction != null)
{
var context = new com.kingsrook.qqq.backend.core.context.CapturedContext(QContext.getQInstance(), QContext.getQSession());
transaction.addAfterRollbackCallback(() -> QContext.withTemporaryContext(context, () -> storage.delete(bucket, key)));
}
boolean rowSaved = false;
try
{
@@ -303,6 +313,22 @@ public class AttachmentsService
*******************************************************************************/
void deleteStoredRow(QRecord row, boolean cleanup) throws QException
{
if(QContext.getQBackendTransaction() != null)
{
var context = new com.kingsrook.qqq.backend.core.context.CapturedContext(QContext.getQInstance(), QContext.getQSession());
QContext.getQBackendTransaction().addAfterCommitCallback(() ->
{
try
{
QContext.withTemporaryContext(context, () -> deleteStoredRow(row, cleanup));
}
catch(QException e)
{
throw (new IllegalStateException("Attachment deletion requires retry", e));
}
});
return;
}
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());
@@ -83,7 +83,7 @@ public final class CustomFieldStorage
}
if(!postgres(table))
{
return (new Availability(false, "Backend does not support JsonPathStorage; EAV fields are read-only filters (values are not selected)."));
return (new Availability(false, "Backend does not support JsonPathStorage; EAV fields support read-only values and filters."));
}
try(Connection connection = connection(table); var statement = connection.createStatement())
{
@@ -92,7 +92,7 @@ public final class CustomFieldStorage
}
catch(SQLException | IllegalArgumentException e)
{
return (new Availability(false, "JSON column unavailable; EAV fields are read-only filters. " + e.getMessage()));
return (new Availability(false, "JSON column unavailable; EAV fields support read-only values and filters. " + e.getMessage()));
}
}
@@ -0,0 +1,101 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.customfields;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.query.VirtualFieldValueProviderInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Supply EAV values for the parent records selected by the core query pipeline.
** Do not re-read parents here: that would recursively execute this provider.
*******************************************************************************/
public class CustomFieldValueProvider implements VirtualFieldValueProviderInterface
{
/*******************************************************************************
** Bound each lookup and retain the caller's transaction through service storage.
*******************************************************************************/
@Override
public void fill(QTableMetaData table, String fieldName, List<QRecord> records, QueryInput input) throws QException
{
String name = fieldName.substring(3);
var definitions = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter()
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("name", QCriteriaOperator.EQUALS, name)
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"), input.getTransaction());
if(definitions.size() != 1)
{
throw (new QException("Custom field is unavailable"));
}
String column = FieldCriteriaExpander.valueColumn(definitions.getFirst().getValueString("type"));
for(List<QRecord> page : CollectionUtils.getPages(records, 500))
{
List<String> ids = page.stream().map(record -> String.valueOf(record.getValue(table.getPrimaryKeyField()))).distinct().toList();
Map<String, QRecord> values = new HashMap<>();
for(QRecord value : valuesForSelectedParents(new QQueryFilter()
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("fieldName", QCriteriaOperator.EQUALS, name)
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false).withCriteria("recordId", QCriteriaOperator.IN, ids), input))
{
if(values.put(value.getValueString("recordId"), value) != null)
{
throw (new QException("Duplicate custom field values"));
}
}
for(QRecord record : page)
{
QRecord value = values.get(String.valueOf(record.getValue(table.getPrimaryKeyField())));
record.setValue(fieldName, value == null ? null : ValueUtils.getValueAsFieldType(table.getVirtualField(fieldName).getType(), value.getValue(column)));
}
}
}
/*******************************************************************************
** The enclosing query already selected these parents under its access policy.
** Remove only recursive parent verification on a private child metadata clone;
** preserve child locks and never install this metadata on the instance.
*******************************************************************************/
private List<QRecord> valuesForSelectedParents(QQueryFilter filter, QueryInput origin) throws QException
{
QTableMetaData metadata = QContext.getQInstance().getTable(CollaborationTables.FIELD_VALUE).clone();
metadata.withCustomizers(null);
metadata.getSupplementalMetaData().remove(RecordReferenceTableMetaData.NAME);
QueryInput input = new QueryInput(CollaborationTables.FIELD_VALUE).withFilter(filter)
.withShouldFetchHeavyFields(true).withTransaction(origin.getTransaction());
input.setTableMetaData(metadata);
input.withPermissionBypass("EAV provider reads only parents selected by the enclosing query.");
return (new QueryAction().execute(input).getRecords());
}
}
@@ -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,
@@ -119,7 +122,8 @@ public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
String name = "cf_" + definition.getValueString("name");
QFieldType type = type(definition.getValueString("type"));
QFieldMetaData field = json ? new QFieldMetaData(name, type).withBackendStorage(new JsonPathStorage().withColumnName("qqq_custom_fields").withKey(definition.getValueString("name")))
: new QVirtualFieldMetaData(name, type).withIsEditable(false).withIsQueryCriteria(true).withIsQuerySelectable(false)
: new QVirtualFieldMetaData(name, type).withIsEditable(false).withIsQueryCriteria(true).withIsQuerySelectable(true)
.withValueProvider(new QCodeReference(CustomFieldValueProvider.class))
.withCriteriaExpander(new QCodeReference(FieldCriteriaExpander.class));
field.withLabel(definition.getValueString("label")).withIsRequired(json && Boolean.TRUE.equals(definition.getValueBoolean("required")))
.withMaxLength(definition.getValueInteger("maxLength"));
@@ -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"));
}
}
}
@@ -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;
}
@@ -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)
{
}
}
@@ -0,0 +1,90 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.tags;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.query.VirtualFieldValueProviderInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** Return approved tag labels for parents selected by the enclosing query.
*******************************************************************************/
public class TagValueProvider implements VirtualFieldValueProviderInterface
{
/*******************************************************************************
** Batch both assignments and vocabulary; never recursively load parent records.
*******************************************************************************/
@Override
public void fill(QTableMetaData table, String fieldName, List<QRecord> records, QueryInput origin) throws QException
{
for(List<QRecord> page : CollectionUtils.getPages(records, 500))
{
List<String> ids = page.stream().map(record -> String.valueOf(record.getValue(table.getPrimaryKeyField()))).distinct().toList();
QTableMetaData metadata = QContext.getQInstance().getTable(CollaborationTables.RECORD_TAG).clone();
metadata.withCustomizers(null);
metadata.getSupplementalMetaData().remove(RecordReferenceTableMetaData.NAME);
QueryInput input = new QueryInput(CollaborationTables.RECORD_TAG).withTransaction(origin.getTransaction()).withFilter(new QQueryFilter()
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("recordId", QCriteriaOperator.IN, ids)
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false));
input.setTableMetaData(metadata);
input.withPermissionBypass("Tag provider reads only parents selected by the enclosing query.");
List<QRecord> assignments = new QueryAction().execute(input).getRecords();
Map<Integer, String> labels = new HashMap<>();
List<Integer> tagIds = assignments.stream().map(row -> row.getValueInteger("tagId")).distinct().toList();
for(List<Integer> tags : CollectionUtils.getPages(tagIds, 500))
{
for(QRecord tag : CollaborationRecords.query(CollaborationTables.TAG, TagsService.vocabularyFilter(table.getName())
.withCriteria("approved", QCriteriaOperator.EQUALS, true).withCriteria("id", QCriteriaOperator.IN, tags), origin.getTransaction()))
{
labels.put(tag.getValueInteger("id"), tag.getValueString("label"));
}
}
Map<String, java.util.SortedSet<String>> byParent = new HashMap<>();
for(QRecord assignment : assignments)
{
String label = labels.get(assignment.getValueInteger("tagId"));
if(label != null)
{
byParent.computeIfAbsent(assignment.getValueString("recordId"), key -> new java.util.TreeSet<>()).add(label);
}
}
for(QRecord record : page)
{
var values = byParent.get(String.valueOf(record.getValue(table.getPrimaryKeyField())));
record.setValue(fieldName, values == null ? null : String.join(", ", values));
}
}
}
}
@@ -56,7 +56,8 @@ public class TagsMetaDataProvider implements RuntimeMetaDataProviderInterface
{
contribution.getTables().put(table.getName(), new RuntimeTableMetaDataContribution()
.withVirtualFields(List.of(new QVirtualFieldMetaData("collaborationTags", QFieldType.STRING).withLabel("Tags")
.withPossibleValueSourceName(CollaborationTables.TAG).withIsQueryCriteria(true).withIsQuerySelectable(false)
.withPossibleValueSourceName(CollaborationTables.TAG).withIsQueryCriteria(true).withIsQuerySelectable(true)
.withValueProvider(new QCodeReference(TagValueProvider.class)).withShouldTranslatePossibleValues(false)
.withCriteriaExpander(new QCodeReference(TagCriteriaExpander.class))))
.withSearchFields(List.of("collaborationTags")));
}
@@ -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);
}
/*******************************************************************************
@@ -39,6 +39,12 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
@@ -63,6 +69,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 +254,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.
*******************************************************************************/
@@ -346,6 +390,80 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
assertThat(tagQuery("IN", "[\"alpha\"]")).containsExactlyInAnyOrder(1, 2);
}
/*******************************************************************************
** EAV choices retain their stored ids and translated labels on the HTTP wire.
*******************************************************************************/
@Test
void customFieldValuesThroughTableReads() throws Exception
{
admin();
instance.addBackend(new RDBMSBackendMetaData().withName("fallback").withVendor("h2"));
instance.getTable("document").setBackendName("fallback");
QRecord definition = new InsertAction().execute(new InsertInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
new QRecord().withValue("tableName", "document").withValue("name", "choice").withValue("label", "Choice").withValue("type", "SELECT")
.withValue("options", "[{\"id\":\"x\",\"label\":\"Choice X\"}]")))).getRecords().getFirst();
assertThat(definition.getErrors()).isNullOrEmpty();
FieldDefinitions.apply(definition.getValue("id"), "Publish");
instance.getTable("document").setBackendName("memory");
CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "1")
.withValue("fieldName", "choice").withValue("valueText", "x").withValue("parentDeleted", false));
login("alice");
String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/"));
HttpResponse<String> response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/query")).timeout(Duration.ofSeconds(20))
.header("Cookie", "sessionUUID=alice" + sessionSuffix).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"filter\":{\"orderBys\":[{\"fieldName\":\"id\"}]}}")).build(), HttpResponse.BodyHandlers.ofString());
JsonNode records = json(response, 200).get("records");
assertThat(records).hasSize(2);
assertThat(records.get(0).at("/values/id").asInt()).isEqualTo(1);
assertThat(records.get(0).at("/values/cf_choice").asText()).isEqualTo("x");
assertThat(records.get(0).at("/displayValues/cf_choice").asText()).isEqualTo("Choice X");
assertThat(records.get(1).at("/values/cf_choice").isMissingNode()).isTrue();
response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20))
.header("Cookie", "sessionUUID=alice" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString());
JsonNode record = json(response, 200).get("record");
assertThat(record.at("/values/cf_choice").asText()).isEqualTo("x");
assertThat(record.at("/displayValues/cf_choice").asText()).isEqualTo("Choice X");
response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20))
.header("Cookie", "sessionUUID=outsider" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(404);
assertThat(response.body()).doesNotContain("Choice X", "First document");
}
/*******************************************************************************
** Table reads expose tag labels while retaining parent-record authorization.
*******************************************************************************/
@Test
void tagValuesThroughTableReads() throws Exception
{
RuntimeMetaDataOverlay.refresh(instance);
int tag = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Review, urgent\"}").get("id").asInt();
ok("PUT", "/records/document/1/tags", "{\"add\":[" + tag + "]}");
String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/"));
HttpResponse<String> response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/query")).timeout(Duration.ofSeconds(20))
.header("Cookie", "sessionUUID=alice" + sessionSuffix).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"filter\":{\"orderBys\":[{\"fieldName\":\"id\"}]}}")).build(), HttpResponse.BodyHandlers.ofString());
JsonNode records = json(response, 200).get("records");
assertThat(records).hasSize(2);
assertThat(records.get(0).at("/values/id").asInt()).isEqualTo(1);
assertThat(records.get(0).at("/values/collaborationTags").asText()).isEqualTo("Review, urgent");
assertThat(records.get(0).at("/displayValues/collaborationTags").asText()).isEqualTo("Review, urgent");
assertThat(records.get(1).at("/values/id").asInt()).isEqualTo(2);
assertThat(records.get(1).at("/values/collaborationTags").isMissingNode()).as(records.toString()).isTrue();
response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20))
.header("Cookie", "sessionUUID=alice" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString());
JsonNode record = json(response, 200).get("record");
assertThat(record.at("/values/collaborationTags").asText()).isEqualTo("Review, urgent");
assertThat(record.at("/displayValues/collaborationTags").asText()).isEqualTo("Review, urgent");
response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/1")).timeout(Duration.ofSeconds(20))
.header("Cookie", "sessionUUID=outsider" + sessionSuffix).GET().build(), HttpResponse.BodyHandlers.ofString());
assertThat(response.body()).doesNotContain("Review, urgent", "First document");
assertThat(response.statusCode()).isEqualTo(404);
}
/*******************************************************************************
** Run the v1 table query filtered on the virtual tags field; return matching ids.
*******************************************************************************/
@@ -402,6 +520,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 +642,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");
}
/*******************************************************************************
@@ -84,7 +84,8 @@ class CoreContractsTest extends PersonalServiceFixture
RuntimeMetaDataOverlay.refresh(instance);
long generation = RuntimeMetaDataOverlay.getGeneration(instance);
assertThat(instance.getTable("document").getVirtualFields()).containsKey("collaborationTags");
assertThat(instance.getTable("document").getVirtualField("collaborationTags").getIsQuerySelectable()).isFalse();
assertThat(instance.getTable("document").getVirtualField("collaborationTags").getIsQuerySelectable()).isTrue();
assertThat(instance.getTable("document").getVirtualField("collaborationTags").getValueProvider()).isNotNull();
QBackendTransaction tx = new QBackendTransaction();
var row = new InsertAction().execute(new InsertInput(CollaborationTables.FEATURE_SETTING).withTransaction(tx).withRecords(List.of(
new QRecord().withValue("scopeType", "TABLE").withValue("scopeName", "document").withValue("feature", "TAGS")
@@ -0,0 +1,188 @@
/*
* 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.sql.Connection;
import java.sql.DriverManager;
import java.util.Set;
import com.kingsrook.qbits.collaboration.customfields.CustomFieldValueProvider;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.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.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Verify provider reads using a real database and explicit caller transactions.
*******************************************************************************/
class CustomFieldValuesPostgresTest extends PersonalServiceFixture
{
private static GenericContainer<?> postgres;
/*******************************************************************************
** Own an isolated database; never connect to an application database.
*******************************************************************************/
@BeforeAll
static void startPostgres()
{
postgres = new GenericContainer<>("postgres:17-alpine").withEnv("POSTGRES_PASSWORD", "eav-fixture-only")
.withExposedPorts(5432).waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*", 2));
postgres.start();
}
/*******************************************************************************
** Stop only the fixture's database.
*******************************************************************************/
@AfterAll
static void stopPostgres()
{
if(postgres != null)
{
postgres.stop();
}
}
/*******************************************************************************
** Reuse the secured host metadata with real PostgreSQL storage.
*******************************************************************************/
@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("eav-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')");
}
CollaborationRecords.insert(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("tableName", "document")
.withValue("name", "rating").withValue("label", "Rating").withValue("type", "INTEGER").withValue("status", "ACTIVE"));
instance.getTable("document").withVirtualField(new QVirtualFieldMetaData("cf_rating", QFieldType.INTEGER).withLabel("Rating")
.withIsQuerySelectable(true).withValueProvider(new QCodeReference(CustomFieldValueProvider.class)));
}
/*******************************************************************************
** Release transaction state and pooled connections before parent fixture cleanup.
*******************************************************************************/
@Override
public void tearDown()
{
QContext.clearTransaction();
ConnectionManager.closeConnectionProviders(instance);
super.tearDown();
}
/*******************************************************************************
** A separate connection establishes committed visibility independently.
*******************************************************************************/
private Connection connect() throws Exception
{
return (DriverManager.getConnection("jdbc:postgresql://" + postgres.getHost() + ":" + postgres.getMappedPort(5432)
+ "/postgres", "postgres", "eav-fixture-only"));
}
/*******************************************************************************
** Both vocabulary and assignments use the explicit transaction's visibility.
*******************************************************************************/
@ParameterizedTest
@ValueSource(booleans = {false, true})
void tagLabelsSeePendingVocabularyAndCompletion(boolean commit) throws Exception
{
instance.getTable("document").withVirtualField(new QVirtualFieldMetaData("collaborationTags", QFieldType.STRING).withLabel("Tags")
.withIsQuerySelectable(true).withPossibleValueSourceName(CollaborationTables.TAG).withShouldTranslatePossibleValues(false)
.withValueProvider(new QCodeReference(com.kingsrook.qbits.collaboration.tags.TagValueProvider.class)));
var tags = new com.kingsrook.qbits.collaboration.tags.TagsService();
try(var transaction = new RDBMSTransaction(connect()))
{
QContext.setTransaction(transaction);
var tag = tags.create("document", "Pending transaction label", null);
tags.apply("document", 1, java.util.List.of(tag.id()), java.util.List.of());
QContext.clearTransaction();
assertThat(QContext.getQBackendTransaction()).isNull();
var pending = new QueryAction().execute(new QueryInput("document").withFieldNamesToInclude(Set.of("collaborationTags"))
.withShouldTranslatePossibleValues(true).withShouldGenerateDisplayValues(true).withTransaction(transaction)).getRecords();
assertThat(pending).hasSize(1);
assertThat(pending.getFirst().getValues()).containsOnlyKeys("collaborationTags").containsEntry("collaborationTags", "Pending transaction label");
assertThat(pending.getFirst().getDisplayValue("collaborationTags")).isEqualTo("Pending transaction label");
assertThat(new QueryAction().execute(new QueryInput("document")).getRecords().getFirst().getValue("collaborationTags")).isNull();
if(commit)
{
transaction.commit();
}
else
{
transaction.rollback();
}
}
assertThat(new QueryAction().execute(new QueryInput("document")).getRecords().getFirst().getValue("collaborationTags"))
.isEqualTo(commit ? "Pending transaction label" : null);
}
/*******************************************************************************
** Explicit query transactions see pending values even without thread context.
*******************************************************************************/
@ParameterizedTest
@ValueSource(booleans = {false, true})
void explicitTransactionSeesPendingValueAndCompletion(boolean commit) throws Exception
{
try(var transaction = new RDBMSTransaction(connect()))
{
QContext.setTransaction(transaction);
CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "1")
.withValue("fieldName", "rating").withValue("valueNumber", 42).withValue("parentDeleted", false));
QContext.clearTransaction();
assertThat(QContext.getQBackendTransaction()).isNull();
var pending = new QueryAction().execute(new QueryInput("document").withFieldNamesToInclude(Set.of("cf_rating"))
.withTransaction(transaction)).getRecords();
assertThat(pending).hasSize(1);
assertThat(pending.getFirst().getValues()).containsOnlyKeys("cf_rating").containsEntry("cf_rating", 42);
assertThat(new QueryAction().execute(new QueryInput("document")).getRecords().getFirst().getValue("cf_rating")).isNull();
if(commit)
{
transaction.commit();
}
else
{
transaction.rollback();
}
}
assertThat(new QueryAction().execute(new QueryInput("document")).getRecords().getFirst().getValue("cf_rating")).isEqualTo(commit ? 42 : null);
}
}
@@ -51,6 +51,9 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaDat
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -242,6 +245,59 @@ public class CustomFieldsTest extends PersonalServiceFixture
assertThat(FieldProcesses.metadata()).extracting(process -> process.getLabel()).containsExactly("Publish field", "Archive field", "Rebuild index");
}
/*******************************************************************************
** Typed EAV storage returns the declared Java value through an actual query.
*******************************************************************************/
@ParameterizedTest
@MethodSource("eavValueTypes")
void eavValueTypes(String type, String column, java.io.Serializable stored, java.io.Serializable expected) throws Exception
{
admin();
instance.addBackend(new RDBMSBackendMetaData().withName("fallback").withVendor("h2"));
instance.getTable("document").setBackendName("fallback");
QRecord draft = new QRecord().withValue("tableName", "document").withValue("name", "typed").withValue("label", "Typed").withValue("type", type);
if(List.of("SELECT", "MULTI_SELECT").contains(type))
{
draft.withValue("options", "[{\"id\":\"x\",\"label\":\"Choice X\"}]");
}
if("RECORD_LINK".equals(type))
{
draft.withValue("linkTableName", CollaborationTables.USER);
}
QRecord definition = new InsertAction().execute(new InsertInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(draft))).getRecords().getFirst();
assertThat(definition.getErrors()).isNullOrEmpty();
FieldDefinitions.apply(definition.getValue("id"), "Publish");
instance.getTable("document").setBackendName("memory");
CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "1")
.withValue("fieldName", "typed").withValue(column, stored).withValue("parentDeleted", false));
login("alice");
var records = new QueryAction().execute(new QueryInput("document").withFieldNamesToInclude(java.util.Set.of("cf_typed"))).getRecords();
assertThat(records).hasSize(2);
assertThat(records).allSatisfy(record -> assertThat(record.getValues()).containsOnlyKeys("cf_typed"));
assertThat(records).extracting(record -> record.getValue("cf_typed")).containsExactlyInAnyOrder(expected, null);
Object actual = records.stream().map(record -> record.getValue("cf_typed")).filter(java.util.Objects::nonNull).findFirst().orElseThrow();
assertThat(actual).isExactlyInstanceOf(expected.getClass());
}
/*******************************************************************************
** Keep expectations independent of the provider's column-mapping helper.
*******************************************************************************/
static java.util.stream.Stream<Arguments> eavValueTypes()
{
return (java.util.stream.Stream.of(
Arguments.of("INTEGER", "valueNumber", new BigDecimal("7.0"), 7),
Arguments.of("DECIMAL", "valueNumber", new BigDecimal("12.345"), new BigDecimal("12.345")),
Arguments.of("BOOLEAN", "valueBoolean", false, false),
Arguments.of("DATE", "valueDate", java.time.LocalDate.of(2026, 10, 22), java.time.LocalDate.of(2026, 10, 22)),
Arguments.of("DATE_TIME", "valueDateTime", java.time.Instant.parse("2026-10-22T12:34:56Z"), java.time.Instant.parse("2026-10-22T12:34:56Z")),
Arguments.of("SELECT", "valueText", "x", "x"),
Arguments.of("MULTI_SELECT", "valueText", "[\"x\"]", "[\"x\"]"),
Arguments.of("USER", "valueText", "alice", "alice"),
Arguments.of("RECORD_LINK", "valueText", "alice", "alice"),
Arguments.of("STRING", "valueText", "A, B", "A, B"),
Arguments.of("TEXT", "valueText", "First\nSecond", "First\nSecond")));
}
/*******************************************************************************
** An unsupported JSON backend gets typed, read-only subquery filters.
*******************************************************************************/
@@ -256,18 +312,50 @@ public class CustomFieldsTest extends PersonalServiceFixture
instance.getTable("document").setBackendName("memory");
var virtual = instance.getTable("document").getVirtualField("cf_rating");
assertThat(virtual).isNotNull();
assertThat(virtual.getIsQuerySelectable()).isFalse();
assertThat(virtual.getIsQuerySelectable()).isTrue();
assertThat(virtual.getIsEditable()).isFalse();
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("storage")).isEqualTo("EAV");
CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "1")
.withValue("fieldName", "rating").withValue("valueNumber", new BigDecimal("7")).withValue("parentDeleted", false));
QContext.getQSession().withSecurityKeyValue("allTenants", true);
CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "3")
.withValue("fieldName", "rating").withValue("valueNumber", new BigDecimal("99")).withValue("parentDeleted", false));
login("alice");
var selected = new QueryAction().execute(new QueryInput("document").withFieldNamesToInclude(java.util.Set.of("cf_rating"))).getRecords();
assertThat(selected).hasSize(2);
assertThat(selected).allSatisfy(row -> assertThat(row.getValues()).containsOnlyKeys("cf_rating"));
assertThat(selected).extracting(row -> row.getValue("cf_rating")).containsExactlyInAnyOrder(7, null);
assertThat(query("cf_rating", QCriteriaOperator.GREATER_THAN, 5)).extracting(row -> row.getValueInteger("id")).containsExactly(1);
assertThat(query("cf_rating", QCriteriaOperator.NOT_EQUALS, 7)).extracting(row -> row.getValueInteger("id")).containsExactly(2);
assertThat(new QueryAction().execute(new QueryInput("document").withFilter(new QQueryFilter().withCriteria("cf_rating", QCriteriaOperator.IS_BLANK))).getRecords())
.extracting(row -> row.getValueInteger("id")).containsExactly(2);
// Reuse the same session and query to prove results are not cached across access changes.
QueryInput projected = new QueryInput("document").withFieldNamesToInclude(java.util.Set.of("cf_rating"));
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b"));
assertThat(new QueryAction().execute(projected).getRecords()).extracting(row -> row.getValue("cf_rating")).containsExactly(99);
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("a"));
assertThat(new QueryAction().execute(projected).getRecords()).extracting(row -> row.getValue("cf_rating")).containsExactlyInAnyOrder(7, null);
QContext.getQSession().setPermissions(java.util.Set.of());
assertThatThrownBy(() -> new QueryAction().execute(projected)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
login("alice");
assertThat(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData.of(instance.getTable(CollaborationTables.FIELD_VALUE))).isNotNull();
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.FIELD_VALUE)
.withFilter(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, "document")
.withCriteria("recordId", QCriteriaOperator.IN, List.of("1", "3")))).getRecords())
.extracting(row -> row.getValueString("recordId")).containsExactly("1");
virtual.setIsHidden(true);
assertThat(new QueryAction().execute(new QueryInput("document")).getRecords()).allSatisfy(row -> assertThat(row.getValues()).doesNotContainKey("cf_rating"));
virtual.setIsHidden(false);
admin();
QContext.getQSession().withSecurityKeyValue("allTenants", true);
assertThat(FieldDefinitions.valueCount(FieldDefinitions.get(definition.getValue("id")))).isEqualTo(2);
QRecord deleted = CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "2")
.withValue("fieldName", "rating").withValue("valueNumber", new BigDecimal("88")).withValue("parentDeleted", true));
assertThat(deleted.getValueBoolean("parentDeleted")).isTrue();
login("alice");
assertThat(new QueryAction().execute(new QueryInput("document").withFieldNamesToInclude(java.util.Set.of("cf_rating"))).getRecords())
.extracting(row -> row.getValue("cf_rating")).containsExactlyInAnyOrder(7, null);
admin();
assertThat(FieldDefinitions.valueCount(FieldDefinitions.get(definition.getValue("id")))).isEqualTo(1);
var expander = new com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander();
assertThat(expander.expandSearchTerm(instance.getTable("document"), "text", com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind.TEXT, null)).isNotNull();
assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("DATE")).isEqualTo("valueDate");
@@ -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.
*******************************************************************************/
@@ -73,6 +73,126 @@ public class TagsServiceTest extends PersonalServiceFixture
private final TagsService tags = new TagsService();
/*******************************************************************************
** Tag labels are selectable while the ID-based filter picker remains intact.
*******************************************************************************/
@Test
void selectsTagLabelsWithoutTranslatingThemAsIds() throws Exception
{
TagEntry beta = tags.create("document", "Beta", null);
TagEntry alpha = tags.create("document", "Alpha", null);
tags.apply("document", 1, List.of(beta.id(), alpha.id()), List.of());
com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay.refresh(instance);
var field = instance.getTable("document").getVirtualField("collaborationTags");
assertThat(field.getIsQuerySelectable()).isTrue();
assertThat(field.getPossibleValueSourceName()).isEqualTo(CollaborationTables.TAG);
var rows = new QueryAction().execute(new QueryInput("document").withFieldNamesToInclude(java.util.Set.of("collaborationTags"))
.withShouldTranslatePossibleValues(true).withShouldGenerateDisplayValues(true)).getRecords();
assertThat(rows).hasSize(2);
assertThat(rows).allSatisfy(row -> assertThat(row.getValues()).containsOnlyKeys("collaborationTags"));
assertThat(rows).extracting(row -> row.getValueString("collaborationTags")).containsExactlyInAnyOrder("Alpha, Beta", null);
assertThat(rows).filteredOn(row -> row.getValue("collaborationTags") != null)
.allSatisfy(row -> assertThat(row.getDisplayValue("collaborationTags")).isEqualTo("Alpha, Beta"));
assertThat(new QueryAction().execute(new QueryInput("document").withFilter(new QQueryFilter()
.withCriteria("collaborationTags", QCriteriaOperator.EQUALS, alpha.id()))).getRecords())
.extracting(row -> row.getValueInteger("id")).containsExactly(1);
}
/*******************************************************************************
** Exported labels obey parent visibility and vocabulary approval, with CSV quoting.
*******************************************************************************/
@Test
void tagLabelsRespectPrivacyAndExportQuoting() throws Exception
{
TagEntry visible = tags.create("document", "Needs, review", null);
TagEntry secret = tags.create("document", "Secret label", null);
tags.apply("document", 1, List.of(visible.id()), List.of());
QContext.getQSession().withSecurityKeyValue("allTenants", true);
tags.apply("document", 3, List.of(secret.id()), List.of());
config.withDefaultTagMode("APPROVED_ONLY");
TagEntry pending = tags.create("document", "Pending label", null);
assertThat(pending.approved()).isFalse();
// A stale assignment must not turn a pending suggestion into a visible label.
CollaborationRecords.insert(CollaborationTables.RECORD_TAG, new QRecord().withValue("tableName", "document")
.withValue("recordId", "1").withValue("tagId", pending.id()).withValue("createdByUserId", "alice").withValue("parentDeleted", false));
login("alice");
com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay.refresh(instance);
var input = new QueryInput("document").withFieldNamesToInclude(java.util.Set.of("collaborationTags"));
assertThat(new QueryAction().execute(input).getRecords()).extracting(row -> row.getValueString("collaborationTags"))
.containsExactlyInAnyOrder("Needs, review", null);
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
var export = new com.kingsrook.qqq.backend.core.model.actions.reporting.ExportInput();
export.setTableName("document");
export.setFieldNames(List.of("collaborationTags"));
export.setReportDestination(new com.kingsrook.qqq.backend.core.model.actions.reporting.ReportDestination()
.withReportFormat(com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat.CSV).withReportOutputStream(output));
new com.kingsrook.qqq.backend.core.actions.reporting.ExportAction().execute(export);
String csv = output.toString(java.nio.charset.StandardCharsets.UTF_8);
assertThat(csv).doesNotContain("Secret label", "Pending label");
try(var parser = org.apache.commons.csv.CSVParser.parse(csv, org.apache.commons.csv.CSVFormat.DEFAULT.withFirstRecordAsHeader()))
{
assertThat(parser.getHeaderNames()).containsExactly("Tags");
assertThat(parser.getRecords()).extracting(row -> row.get("Tags")).containsExactlyInAnyOrder("Needs, review", "");
}
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b"));
assertThat(new QueryAction().execute(input).getRecords()).extracting(row -> row.getValueString("collaborationTags")).containsExactly("Secret label");
login("alice");
assertThat(new QueryAction().execute(input).getRecords()).extracting(row -> row.getValueString("collaborationTags"))
.containsExactlyInAnyOrder("Needs, review", null);
}
/*******************************************************************************
** A page spanning the provider's batch boundary retains its last parent values.
*******************************************************************************/
@Test
void tagLabelsSpanMultipleParentBatches() throws Exception
{
java.util.List<QRecord> parents = new java.util.ArrayList<>();
for(int id = 4; id <= 504; id++)
{
parents.add(new QRecord().withValue("id", id).withValue("title", "Document " + id).withValue("tenant", "a"));
}
assertThat(new InsertAction().execute(new InsertInput("document").withRecords(parents)).getRecords())
.allSatisfy(row -> assertThat(row.getErrors()).isNullOrEmpty());
TagEntry tag = tags.create("document", "Boundary", null);
tags.apply("document", 1, List.of(tag.id()), List.of());
tags.apply("document", 504, List.of(tag.id()), List.of());
com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay.refresh(instance);
var rows = new QueryAction().execute(new QueryInput("document").withFieldNamesToInclude(java.util.Set.of("id", "collaborationTags"))).getRecords();
assertThat(rows).hasSize(503);
assertThat(rows).filteredOn(row -> row.getValue("collaborationTags") != null).extracting(row -> row.getValueInteger("id"))
.containsExactlyInAnyOrder(1, 504);
assertThat(rows).filteredOn(row -> row.getValue("collaborationTags") != null)
.allSatisfy(row -> assertThat(row.getValueString("collaborationTags")).isEqualTo("Boundary"));
}
/*******************************************************************************
** Stale links cannot expose merged vocabulary, other-table tags, or deleted links.
*******************************************************************************/
@Test
void tagLabelsExcludeStaleAndOutOfScopeAssignments() throws Exception
{
login("admin", TagsService.MANAGE);
TagEntry global = tags.create(null, "Global", null);
TagEntry current = tags.create("document", "Current", null);
TagEntry merged = tags.create("document", "Merged old label", null);
TagEntry deleted = tags.create("document", "Deleted link label", null);
TagEntry other = tags.create("document", "Other table label", null);
CollaborationRecords.update(CollaborationTables.TAG, new QRecord().withValue("id", merged.id()).withValue("mergedIntoTagId", current.id()));
CollaborationRecords.update(CollaborationTables.TAG, new QRecord().withValue("id", other.id()).withValue("tableName", "different_table"));
for(TagEntry tag : List.of(global, current, merged, deleted, other))
{
CollaborationRecords.insert(CollaborationTables.RECORD_TAG, new QRecord().withValue("tableName", "document").withValue("recordId", "1")
.withValue("tagId", tag.id()).withValue("createdByUserId", "admin").withValue("parentDeleted", tag.id().equals(deleted.id())));
}
login("alice");
com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay.refresh(instance);
var rows = new QueryAction().execute(new QueryInput("document").withFieldNamesToInclude(java.util.Set.of("collaborationTags"))).getRecords();
assertThat(rows).extracting(row -> row.getValueString("collaborationTags")).containsExactlyInAnyOrder("Current, Global", null);
assertThat(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData.of(instance.getTable(CollaborationTables.RECORD_TAG))).isNotNull();
}
/*******************************************************************************
** Natural keys are locale-independent and validate after collapsing whitespace.
*******************************************************************************/
@@ -0,0 +1,174 @@
/*
* 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 CapacityPresenceDevServer
{
/***************************************************************************
** 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(System.getProperty("collab.capacity.redisHost", "127.0.0.1")).withPort(redisPort).withTls(false).withMaximumConnections(8).withMaximumWaitMillis(500))
.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(FixtureTenant.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);
var sampler = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
try
{
redis.start(instance);
transport.start(instance);
health.start();
server.start();
sampler.scheduleAtFixedRate(() ->
{
var pool = RedisRuntimeService.connection(instance, "presence").metrics();
var heap = java.lang.management.ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
var values = new java.util.LinkedHashMap<String, Object>();
values.put("uptimeMillis", java.lang.management.ManagementFactory.getRuntimeMXBean().getUptime());
values.put("cpuNanos", ProcessHandle.current().info().totalCpuDuration().map(java.time.Duration::toNanos).orElse(-1L));
values.put("heapUsedBytes", heap.getUsed());
values.put("heapCommittedBytes", heap.getCommitted());
values.put("residentBytes", residentBytes());
values.put("poolActive", pool.active());
values.put("poolIdle", pool.idle());
values.put("poolWaiting", pool.waiting());
values.put("poolMaximum", pool.maximum());
System.out.println("CAPACITY_METRIC " + com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(values));
}, 0, 100, java.util.concurrent.TimeUnit.MILLISECONDS);
System.out.println("CAPACITY_PRESENCE_READY " + port);
Thread.currentThread().join();
}
finally
{
sampler.shutdownNow();
server.stop();
health.stop();
transport.stop();
PresenceRegistry.close(instance);
RealtimeBus.close(instance);
redis.stop();
redis.afterApplicationStop();
}
}
/***************************************************************************
** Read Linux process resident memory; unsupported platforms report -1.
***************************************************************************/
private static long residentBytes()
{
try
{
for(String line : java.nio.file.Files.readAllLines(java.nio.file.Path.of("/proc/self/status")))
{
if(line.startsWith("VmRSS:"))
{
return (Long.parseLong(line.trim().split("\\s+")[1]) * 1024L);
}
}
}
catch(java.io.IOException | NumberFormatException e)
{
return (-1L);
}
return (-1L);
}
/***************************************************************************
** Tenant comes from the configured mock identity, never a request tenant parameter.
***************************************************************************/
public static class FixtureTenant implements PresenceScopeProvider
{
/************************************************************************
** Identical record IDs are isolated by the authenticated fixture identity.
************************************************************************/
@Override
public String tenantScope()
{
String identity = com.kingsrook.qqq.backend.core.context.QContext.getQSession().getUser().getIdReference();
if(!identity.matches("load-t[0-7]-u[0-9]{1,2}"))
{
throw (new IllegalStateException("Capacity presence requires a configured load identity"));
}
return (identity.substring(0, identity.indexOf("-u")));
}
}
/***************************************************************************
** 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");
}
}
}
}
@@ -123,11 +123,35 @@ import io.javalin.config.JavalinConfig;
public class CollaborationDevServer extends AbstractQQQApplication
{
private final QInstance instance;
static final List<String> USERS = List.of("alice", "bob", "admin");
static final List<String> USERS = fixtureUsers();
static final String USER_COOKIE = "collab_dev_user";
private static final ThreadLocal<String> REQUEST_USER = new ThreadLocal<>();
/*******************************************************************************
** Opt-in bounded identities for the disposable capacity fixture only.
*******************************************************************************/
private static List<String> fixtureUsers()
{
int tenants = Integer.getInteger("collab.capacity.tenants", 0);
int users = Integer.getInteger("collab.capacity.users", 0);
if(tenants < 0 || tenants > 8 || users < 0 || users > 100)
{
throw (new IllegalArgumentException("Capacity fixture permits at most 8 tenants and 100 users each"));
}
var identities = new java.util.ArrayList<>(List.of("alice", "bob", "admin"));
for(int tenant = 0; tenant < tenants; tenant++)
{
for(int user = 0; user < users; user++)
{
identities.add("load-t" + tenant + "-u" + user);
}
}
return (List.copyOf(identities));
}
/*******************************************************************************
** Keep the already seeded instance when Javalin initializes the application.
@@ -186,7 +210,7 @@ public class CollaborationDevServer extends AbstractQQQApplication
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
.withJavalinConfigCustomizer(config ->
{
config.jetty.host = "127.0.0.1";
config.jetty.host = System.getProperty("collab.dev.bindAddress", "127.0.0.1");
userSelection(config);
MemoryStorage.routes(config);
}));
@@ -310,6 +334,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)));
}
@@ -329,7 +356,7 @@ public class CollaborationDevServer extends AbstractQQQApplication
{
case "alice" -> "Alice Morgan";
case "bob" -> "Bob Chen";
default -> "Demo Administrator";
default -> user.equals("admin") ? "Demo Administrator" : user;
};
QSession session = new QSession().withUser(new QUser().withIdReference(user).withFullName(name))
.withPermissions("person.read", "person.comment")
@@ -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");
}
}
}
}
@@ -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"));
}
+11
View File
@@ -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>