Skip to content

Read member adoption details for an organization - #8315

Draft
maxkpower wants to merge 4 commits into
dirt/prototype/adoption-report/device-indexfrom
dirt/prototype/adoption-report/data-access
Draft

Read member adoption details for an organization#8315
maxkpower wants to merge 4 commits into
dirt/prototype/adoption-report/device-indexfrom
dirt/prototype/adoption-report/data-access

Conversation

@maxkpower

Copy link
Copy Markdown

🎟️ Tracking

PM-35924

📔 Objective

Adds the data access for the member adoption report: one row per confirmed organization member with login recency, browser extension use, vault item count, reachable shared item count, and sponsorship redemption.

  • Ships a Dapper stored procedure and a hand written EF LINQ translation. The EF path is deliberately not a FromSqlRaw/EXEC passthrough, which is what makes the sibling Risk Insights and Member Access reports MSSQL only.
  • SharedItemCount counts organization ciphers reachable directly via CollectionUser and through a group via GroupUser/CollectionGroup. Both de-duplication steps are load bearing: the union collapses a collection reachable by both paths, and the separate distinct collapses a cipher sitting in more than one reachable collection. Dropping either inflates the count.
  • Only OrganizationUser.Status = 2 is counted. Invited and Accepted members are deliberately excluded.
  • MemberAdoptionReportRepositorySqliteTests runs against a real in-memory SQLite context with no configuration, so it executes on every PR. The sibling [CiSkippedTheory] harness never runs in CI.

Middle of a 3 PR stack, on device-index. endpoint sits on this.

📸 Screenshots

No user-visible change.

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.40523% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.64%. Comparing base (b5eca58) to head (b529b9a).

Files with missing lines Patch % Lines
...eports/ReportFeatures/SharedItemCountCalculator.cs 93.85% 7 Missing and 4 partials ⚠️
Additional details and impacted files
@@                               Coverage Diff                               @@
##           dirt/prototype/adoption-report/device-index    #8315      +/-   ##
===============================================================================
+ Coverage                                        69.56%   69.64%   +0.07%     
===============================================================================
  Files                                             2471     2476       +5     
  Lines                                           105935   106241     +306     
  Branches                                          9601     9636      +35     
===============================================================================
+ Hits                                             73695    73991     +296     
- Misses                                           29777    29783       +6     
- Partials                                          2463     2467       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@maxkpower
maxkpower force-pushed the dirt/prototype/adoption-report/data-access branch from 0442193 to 3a26e37 Compare September 4, 2026 00:26
@maxkpower maxkpower added the ai-review Request a Claude code review label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

Re-reviewed after the three commits that moved the shared item count out of SQL and into SharedItemCountCalculator. Traced the calculator end to end — the exclusive/shared cipher split, the CSR-style buffers, the in-place sort-and-dedup of each member's collection range, and the access-set memoization are all correct, and the ranges the memo dictionary keys on are finalized before they are stored, so the comparer never reads a mutated key. Dual-ORM parity holds on the new work: the access-graph procedure's UNION matches EF's Union() (not Concat), both sides scope collections and ciphers to the organization and drop soft-deleted ciphers, the D.[Type] IN (2, 3, 4, 5, 19, 20) list still matches DeviceTypes.BrowserExtensionTypes exactly, and the rewritten detail procedure's [MemberVaultItem] CTE and folded DEV apply produce the same rows as the per-member OUTER APPLYs they replace. The added cipher.UserId != null guard in the EF projection is a real fix — Cipher.UserId is nullable, so EF's null semantics would otherwise have attributed every unowned cipher to every member without a linked user, and the new SQLite test pins it. Device.UserId is non-nullable so the device subqueries never needed the same guard. Migration/SSDT parity, CREATE OR ALTER idempotency, InternalsVisibleTo (an established pattern in this repo), and DI registration on both tracks all check out. One CI-blocking issue, and one question carried over from the previous round.

Code Review Details
  • ⚠️ : Three new .cs files are missing the UTF-8 BOM required by .editorconfig, failing the Lint job and skipping the Docker image and migrator builds
    • src/Core/Dirt/Reports/ReportFeatures/SharedItemCountCalculator.cs:1
    • src/Core/Dirt/Models/Data/MemberAdoptionReportAccessGraph.cs:1
    • test/Core.Test/Dirt/ReportFeatures/SharedItemCountCalculatorTests.cs:1
  • ❓ : Still open from the previous round, and now applying to the access-graph reads rather than the old aggregate: [OrganizationCollection] and the EF Collections joins take every collection in the organization, including Type = 1 (DefaultUserCollection), so a migrated member's own My Items collection contributes to SharedItemCount while VaultItemCount drops to 0. The existing thread is marked outdated because the procedure was rewritten, so flagging it here rather than duplicating the comment
    • src/Sql/dbo/Dirt/Stored Procedures/MemberAdoptionReport_ReadAccessGraphByOrganizationId.sql:12
    • src/Infrastructure.EntityFramework/Dirt/Repositories/MemberAdoptionReportRepository.cs:49

Guid organizationId)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QUESTION: The Dapper path sets commandTimeout: 120, but this EF path keeps the provider default (30s).

Details

MemberAdoptionReportRepository (Dapper) passes commandTimeout: 120 — signalling that the report is expected to exceed the 30s default on a large organization. The EF translation runs the same two aggregations with no timeout override, and I couldn't find a global CommandTimeout / SetCommandTimeout anywhere in src/Infrastructure.EntityFramework or src/SharedWeb, so MySQL/PostgreSQL/SQLite fall back to the 30s provider default.

If the intent is parity, dbContext.Database.SetCommandTimeout(120) before the two queries would match. If self-hosted EF deployments are assumed small enough that 30s is fine, no change needed — just wanted to confirm the asymmetry is deliberate.

Comment on lines +10 to +17
WITH [OrganizationCollection] AS (
SELECT
COL.[Id]
FROM
[dbo].[Collection] COL
WHERE
COL.[OrganizationId] = @OrganizationId
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QUESTION: SharedItemCount includes each member's own default (My Items) collection — should Collection.Type = 1 be excluded?

Details

[OrganizationCollection] takes every collection in the org, including Type = 1 (CollectionType.DefaultUserCollection). Collection_CreateDefaultCollections links that collection to its owner through CollectionUser, and the ciphers inside it are org-owned (Cipher.OrganizationId = @OrganizationId, UserId IS NULL), so they satisfy [OrganizationCollectionCipher] and land in the member's SharedItemCount.

For an org with UseMyItems enabled, the effect on a member who has been migrated is that their own items count as shared items while VaultItemCount (which requires Cipher.OrganizationId IS NULL) drops to 0 — so the two columns swap meaning rather than describing personal vs shared usage. The codebase already has a precedent for drawing this line: CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections filters [CollectionType] <> 1, and CollectionCipher_UpdateCollectionsAdmin uses C.[Type] <> 1.

If the report is meant to measure genuinely shared items, adding AND COL.[Type] <> 1 here (and the matching collection.Type != CollectionType.DefaultUserCollection in the EF directCollectionAccess/groupCollectionAccess queries) would keep the two counts distinct. If counting them is deliberate for adoption purposes, no change needed — the test collections are all built as CollectionType.SharedCollection, so the current behaviour isn't exercised either way.

@maxkpower
maxkpower force-pushed the dirt/prototype/adoption-report/data-access branch from f4db932 to b529b9a Compare September 4, 2026 23:49
@@ -0,0 +1,264 @@
using System.Runtime.InteropServices;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: This file and two others are missing the UTF-8 BOM that .editorconfig requires for *.cs, which fails the Lint job.

Details and fix

.editorconfig sets charset = utf-8-bom for [*.{cs,csx,vb,vbx}], and dotnet format enforces it. Three of the new .cs files in this PR are plain ASCII with no BOM, while every other new file in the same diff (MemberAdoptionReportDetail.cs, both repositories, the test files) has one:

  • src/Core/Dirt/Models/Data/MemberAdoptionReportAccessGraph.cs
  • src/Core/Dirt/Reports/ReportFeatures/SharedItemCountCalculator.cs
  • test/Core.Test/Dirt/ReportFeatures/SharedItemCountCalculatorTests.cs

Lint (dotnet format --verify-no-changes) is currently failing on this PR and passing on the base PR, and build-artifacts plus build-mssqlmigratorutility both declare needs: lint, so the Docker image and migrator builds are skipped.

Running dotnet format over the three projects will rewrite them with the BOM. I have not seen the job log, so I can only say these three definitely violate the configured charset — there may be another fixer firing as well, which the same command would also settle.

Reference: .editorconfig:17-19, .github/workflows/build.yml:36-37

Adds MemberAdoptionReport_ReadByOrganizationId and its Entity Framework
translation, returning one row per confirmed member with the last activity
date, whether a browser extension is in use, the personal vault item count,
the number of shared items the member can reach and whether a sponsorship
has been redeemed.

The report is unpaged over an entire organization, so its runtime grows with
member and cipher counts and the implicit 30 second command timeouts are not
a deliberate bound. Both backends read on 120 seconds instead, matching the
other whole-organization Dapper reads, and the Dapper read goes to the read
replica.

The repository contract hands back an IReadOnlyList rather than an
IEnumerable, so a caller can walk the details more than once without the
read being repeated; both implementations already materialize before they
return.
Adds the access-graph edge types and SharedItemCountCalculator, which unions
the collections a member reaches directly or through a group and counts the
distinct organization-owned ciphers behind them.

Nothing calls it yet. It exists so that the Dapper and Entity Framework
repositories can share one implementation of the per-member set union, rather
than each expressing that union relationally, which at organization scale
neither backend completes inside its command timeout.
The detail procedure no longer computes SharedItemCount. A new procedure
returns the member-to-collection and collection-to-cipher edges in one
QueryMultiple round trip, and the Dapper repository folds the counts in
through SharedItemCountCalculator so both backends can share one algorithm.

Removing the serial per-member set union from the procedure is what lets the
report finish; the aggregation now happens over the edges in memory.

What is left of the detail read is also cut to one pass per member. It hit
Device twice per member and drove VaultItemCount through a per-member OUTER
APPLY that the optimizer served from an eager index spool, seeked once per
member. The two Device applies fold into a single aggregate that computes the
last activity date and the browser-extension flag from one seek, and personal
vault items are pre-aggregated set-wise, scoped to the organization's
confirmed members so the count never widens to every account's personal
vault.

Against the 17,001-member org that pass drops the detail read from 319,331
logical reads to 80,623 and warm CPU from roughly 400ms to 180ms. The
sponsorship apply is unchanged: the table is empty in the dev database, so no
improvement could be measured. Output is byte-identical, verified with EXCEPT
in both directions including row position, plus a scratch case covering
members with no devices, only an extension device, only a non-extension
device, a null UserId, and a redeemed sponsorship.
Reads the access graph as flat projections and defers counting to
SharedItemCountCalculator, so the provider translations no longer materialise
the member by cipher cross product.

Covers the three reads the report is built from with SQLite integration tests
against a real, throwaway database.
@maxkpower
maxkpower force-pushed the dirt/prototype/adoption-report/data-access branch from b529b9a to dfe1bf2 Compare September 8, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant