Skip to content

Commit 810a50e

Browse files
authored
perf(sync): look up WorkOS user IDs through an indexed table (#50)
Finding the WP user for a WorkOS user ID (every sign-in and webhook, the WorkOS Users list, the role push) ran get_users() on the _workos_user_id meta. meta_value isn't indexed, so each lookup scanned every row with that key: ~40 ms on a site with 25,000 linked users, for hits and misses alike. {base_prefix}workos_user_links mirrors the meta with the WorkOS ID as the key. The user meta hooks keep it in step with every write through the meta API, and the v4 schema upgrade fills it from the existing meta (25,000 rows in ~0.5 s). The meta stays the source of truth: each hit is checked against it, a stale row is repaired and answered from the meta, and lookups use the meta until the table is filled. Results match the get_users() query: users of the current site, the first by login when two carry one ID. The WorkOS Users list resolves the whole page in two queries instead of one meta scan per row.
1 parent 2dd604a commit 810a50e

12 files changed

Lines changed: 621 additions & 48 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ Per-environment constants (take priority over generic):
164164
| **Sync** | |
165165
| `src/WorkOS/Sync/Controller.php` | Sync controller |
166166
| `src/WorkOS/Sync/UserSync.php` | User sync (inbound/outbound) |
167+
| `src/WorkOS/Sync/UserLinks.php` | WorkOS user ID → WP user lookups through `{base_prefix}workos_user_links` (indexed), mirrored from the `_workos_user_id` meta by the user meta hooks and filled by the v4 schema upgrade. `find()` / `find_many()` check every hit against the meta and use the meta until the table is filled (`workos_user_links_ready`). Writes to the meta that skip the meta API (raw SQL) aren't seen; `UserLinks::rebuild()` refills. |
167168
| `src/WorkOS/Sync/RoleMapper.php` | WorkOS role → WP role mapping |
168169
| `src/WorkOS/Sync/RoleSyncCheck.php` | Which members' WP role doesn't match the role map. Batched (one JOIN query per 1,000 members), run by "Check now" on WorkOS → Users or on an Action Scheduler schedule (`role_sync_schedule`: off/hourly/daily, only offered when AS is loaded). Stores the result in `workos_role_sync_check` (not autoloaded, stale when env/org/role map change) and rechecks single users on role and `workos_org_membership_changed`. Admin screens read `result()`; nothing is computed on page load. |
169170
| `src/WorkOS/Sync/DirectorySync.php` | Directory sync (SCIM) |
@@ -359,6 +360,7 @@ tests/
359360
├── UserHelperTest.php # WorkOS\User + `workos_is_sso_user`/`workos_has_active_session` helpers
360361
├── UserSyncDeprovisionTest.php # User deprovisioning tests
361362
├── UserSyncFindOrCreateTest.php # User find-or-create tests
363+
├── UserLinksTest.php # Link table: follows meta writes, backfill, parity with the get_users() meta lookup, batched lookups
362364
├── UserSyncLinkTest.php # User linking tests
363365
├── UserSyncRelinkProtectionTest.php # Re-link Protection: refuse/force/actions, sign-in, webhook, directory sync
364366
├── UserSyncPushTest.php # Outbound user sync tests

‎src/WorkOS/Admin/Users/RestApi.php‎

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace WorkOS\Admin\Users;
99

1010
use WorkOS\Config;
11+
use WorkOS\Sync\UserLinks;
1112
use WP_Error;
1213
use WP_REST_Request;
1314
use WP_REST_Response;
@@ -170,14 +171,19 @@ public function list_users( WP_REST_Request $request ): WP_REST_Response {
170171
}
171172

172173
$environment_id = Config::get_environment_id();
173-
$users = isset( $result['data'] ) && is_array( $result['data'] )
174-
? array_map(
175-
function ( $user ) use ( $environment_id ) {
176-
return $this->shape_user( is_array( $user ) ? $user : [], $environment_id );
174+
$rows = isset( $result['data'] ) && is_array( $result['data'] ) ? array_filter( $result['data'], 'is_array' ) : [];
175+
176+
// The linked WP users for the whole page at once, not one lookup per row.
177+
$wp_user_ids = UserLinks::find_many( array_map( 'strval', array_column( $rows, 'id' ) ) );
178+
179+
$users = array_values(
180+
array_map(
181+
function ( array $user ) use ( $environment_id, $wp_user_ids ) {
182+
return $this->shape_user( $user, $environment_id, $wp_user_ids );
177183
},
178-
$result['data']
184+
$rows
179185
)
180-
: [];
186+
);
181187

182188
$metadata = isset( $result['list_metadata'] ) && is_array( $result['list_metadata'] )
183189
? $result['list_metadata']
@@ -241,12 +247,13 @@ private function build_upstream_params( WP_REST_Request $request ): array {
241247
* profile picture URL — the list intentionally doesn't render avatars
242248
* to keep the page lean) and adds a server-computed `dashboard_url`.
243249
*
244-
* @param array $user Upstream user object.
245-
* @param string $environment_id Active WorkOS environment ID (for the deep link).
250+
* @param array $user Upstream user object.
251+
* @param string $environment_id Active WorkOS environment ID (for the deep link).
252+
* @param array<string, int> $wp_user_ids Linked WP user IDs by WorkOS user ID (UserLinks::find_many()).
246253
*
247254
* @return array<string, mixed>
248255
*/
249-
private function shape_user( array $user, string $environment_id ): array {
256+
private function shape_user( array $user, string $environment_id, array $wp_user_ids ): array {
250257
$id = isset( $user['id'] ) ? (string) $user['id'] : '';
251258
$email = isset( $user['email'] ) ? (string) $user['email'] : '';
252259

@@ -263,20 +270,7 @@ private function shape_user( array $user, string $environment_id ): array {
263270
// row can wire up the "Send password reset" trigger that lives at
264271
// `POST /workos/v1/admin/users/{wp_user_id}/password-reset`. Empty
265272
// when the WorkOS user has no matching WP row yet.
266-
$wp_user_id = 0;
267-
if ( '' !== $id ) {
268-
$users = get_users(
269-
[
270-
'meta_key' => '_workos_user_id',
271-
'meta_value' => $id,
272-
'number' => 1,
273-
'fields' => 'ID',
274-
]
275-
);
276-
if ( ! empty( $users ) ) {
277-
$wp_user_id = (int) $users[0];
278-
}
279-
}
273+
$wp_user_id = '' !== $id ? (int) ( $wp_user_ids[ $id ] ?? 0 ) : 0;
280274

281275
return [
282276
'id' => $id,

‎src/WorkOS/Database/Schema.php‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
namespace WorkOS\Database;
99

10+
use WorkOS\Sync\UserLinks;
11+
1012
defined( 'ABSPATH' ) || exit;
1113

1214
/**
@@ -22,13 +24,14 @@ class Schema {
2224
/**
2325
* Current schema version.
2426
*/
25-
private const CURRENT_VERSION = 3;
27+
private const CURRENT_VERSION = 4;
2628

2729
/**
2830
* Activation hook — create tables.
2931
*/
3032
public static function activate(): void {
3133
self::create_tables();
34+
UserLinks::rebuild();
3235
update_option( self::VERSION_OPTION, self::CURRENT_VERSION );
3336
}
3437

@@ -48,6 +51,9 @@ public static function maybe_upgrade(): void {
4851
// is already populated, so it's safe to call on every upgrade.
4952
self::migrate_active_environment_to_standalone_option();
5053

54+
// v4: fill the WorkOS ID → WP user table from the existing meta.
55+
UserLinks::rebuild();
56+
5157
update_option( self::VERSION_OPTION, self::CURRENT_VERSION );
5258
}
5359

@@ -142,6 +148,13 @@ private static function create_tables(): void {
142148
KEY idx_event_type (event_type),
143149
KEY idx_user_id (user_id),
144150
KEY idx_created_at (created_at)
151+
) {$charset_collate};
152+
153+
CREATE TABLE {$wpdb->base_prefix}workos_user_links (
154+
workos_user_id varchar(191) NOT NULL,
155+
wp_user_id bigint(20) unsigned NOT NULL,
156+
PRIMARY KEY (workos_user_id,wp_user_id),
157+
KEY idx_wp_user_id (wp_user_id)
145158
) {$charset_collate};";
146159

147160
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
@@ -159,6 +172,7 @@ public static function drop_tables(): void {
159172
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}workos_org_sites" );
160173
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}workos_org_memberships" );
161174
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}workos_organizations" );
175+
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->base_prefix}workos_user_links" );
162176
// phpcs:enable
163177
}
164178
}

‎src/WorkOS/Sync/Controller.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ class Controller extends BaseController {
2020
* @return void
2121
*/
2222
protected function doRegister(): void {
23+
// First, so the link table follows every write to the link meta.
24+
$this->container->singleton( UserLinks::class );
25+
$this->container->get( UserLinks::class );
26+
2327
$this->container->singleton( UserSync::class );
2428
$this->container->get( UserSync::class );
2529

‎src/WorkOS/Sync/RoleMapper.php‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -438,15 +438,6 @@ public static function get_out_of_sync_user_ids(): array {
438438
* @return int|null WP user ID, or null if not found.
439439
*/
440440
private static function get_wp_user_by_workos_id( string $workos_id ): ?int {
441-
$users = get_users(
442-
[
443-
'meta_key' => '_workos_user_id',
444-
'meta_value' => $workos_id,
445-
'number' => 1,
446-
'fields' => 'ID',
447-
]
448-
);
449-
450-
return ! empty( $users ) ? (int) $users[0] : null;
441+
return UserLinks::find( $workos_id );
451442
}
452443
}

0 commit comments

Comments
 (0)