Skip to content

Commit

Permalink
Merge pull request #382 from BeeMargarida/feat/tab_leader_write
Browse files Browse the repository at this point in the history
#15321: Only one tab writes to the DB
  • Loading branch information
roryabraham authored Dec 1, 2023
2 parents d76bed7 + c72152c commit d858361
Show file tree
Hide file tree
Showing 38 changed files with 67,766 additions and 14 deletions.
31 changes: 31 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: e2e

on:
pull_request:
types: [opened, synchronize]
paths: ['tests/e2e/**', 'lib/**', 'package.json', 'package-lock.json']

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-node-

- name: Setup Node
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'

- run: npm ci

- run: npx playwright install --with-deps

- run: npm run e2e
env:
CI: true
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ dist/

# Decrypted private key we do not want to commit
.github/OSBotify-private-key.asc

## Playwright e2e setup
/test-results/
/playwright-report/
/playwright/.cache/
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,3 +405,10 @@ To continuously work on Onyx we have to set up a task that copies content to par
3. Optional: run `npm run build` (if you're working or want to test on a non react-native project)
- `npm link` would actually work outside of `react-native` and it can be used to link Onyx locally for a web only project
4. Copy Onyx to consumer project's `node_modules/react-native-onyx`
# Automated Tests
There are Playwright e2e tests implemented for the web. To run them:
- `npm run e2e` to run the e2e tests
- or `npm run e2e-ui` to run the e2e tests in UI mode
22 changes: 22 additions & 0 deletions lib/ActiveClientManager/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Determines when the client is ready. We need to wait till the init method is called and the leader message is sent.
*/
declare function isReady(): Promise<void>;

/**
* Subscribes to the broadcast channel to listen for messages from other tabs, so that
* all tabs agree on who the leader is, which should always be the last tab to open.
*/
declare function init(): void;

/**
* Returns a boolean indicating if the current client is the leader.
*/
declare function isClientTheLeader(): boolean;

/**
* Subscribes to when the client changes.
*/
declare function subscribeToClientChange(callback: () => {}): void;

export {isReady, init, isClientTheLeader, subscribeToClientChange};
23 changes: 23 additions & 0 deletions lib/ActiveClientManager/index.native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* For native devices, there will never be more than one
* client running at a time, so this lib is a big no-op
*/

function isReady() {
return Promise.resolve();
}

function isClientTheLeader() {
return true;
}

function init() {}

function subscribeToClientChange() {}

export {
isClientTheLeader,
init,
isReady,
subscribeToClientChange,
};
99 changes: 99 additions & 0 deletions lib/ActiveClientManager/index.web.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* When you have many tabs in one browser, the data of Onyx is shared between all of them. Since we persist write requests in Onyx, we need to ensure that
* only one tab is processing those saved requests or we would be duplicating data (or creating errors).
* This file ensures exactly that by tracking all the clientIDs connected, storing the most recent one last and it considers that last clientID the "leader".
*/

import * as Str from '../Str';
import * as Broadcast from '../broadcast';

const NEW_LEADER_MESSAGE = 'NEW_LEADER';
const REMOVED_LEADER_MESSAGE = 'REMOVE_LEADER';

const clientID = Str.guid();
const subscribers = [];
let timestamp = null;

let activeClientID = null;
let setIsReady = () => {};
const isReadyPromise = new Promise((resolve) => {
setIsReady = resolve;
});

/**
* Determines when the client is ready. We need to wait both till we saved our ID in onyx AND the init method was called
* @returns {Promise}
*/
function isReady() {
return isReadyPromise;
}

/**
* Returns a boolean indicating if the current client is the leader.
*
* @returns {Boolean}
*/
function isClientTheLeader() {
return activeClientID === clientID;
}

/**
* Subscribes to when the client changes.
* @param {Function} callback
*/
function subscribeToClientChange(callback) {
subscribers.push(callback);
}

/**
* Subscribe to the broadcast channel to listen for messages from other tabs, so that
* all tabs agree on who the leader is, which should always be the last tab to open.
*/
function init() {
Broadcast.subscribe((message) => {
switch (message.data.type) {
case NEW_LEADER_MESSAGE: {
// Only update the active leader if the message received was from another
// tab that initialized after the current one; if the timestamps are the
// same, it uses the client ID to tie-break
const isTimestampEqual = timestamp === message.data.timestamp;
const isTimestampNewer = timestamp > message.data.timestamp;
if (isClientTheLeader() && (isTimestampNewer || (isTimestampEqual && clientID > message.data.clientID))) {
return;
}
activeClientID = message.data.clientID;

subscribers.forEach(callback => callback());
break;
}
case REMOVED_LEADER_MESSAGE:
activeClientID = clientID;
timestamp = Date.now();
Broadcast.sendMessage({type: NEW_LEADER_MESSAGE, clientID, timestamp});
subscribers.forEach(callback => callback());
break;
default:
break;
}
});

activeClientID = clientID;
timestamp = Date.now();

Broadcast.sendMessage({type: NEW_LEADER_MESSAGE, clientID, timestamp});
setIsReady();

window.addEventListener('beforeunload', () => {
if (!isClientTheLeader()) {
return;
}
Broadcast.sendMessage({type: REMOVED_LEADER_MESSAGE, clientID});
});
}

export {
isClientTheLeader,
init,
isReady,
subscribeToClientChange,
};
10 changes: 10 additions & 0 deletions lib/Onyx.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {Component} from 'react';
import * as Logger from './Logger';
import * as ActiveClientManager from './ActiveClientManager';
import {CollectionKey, CollectionKeyBase, DeepRecord, KeyValueMapping, NullishDeep, OnyxCollection, OnyxEntry, OnyxKey} from './types';

/**
Expand Down Expand Up @@ -293,6 +294,11 @@ declare function hasPendingMergeForKey(key: OnyxKey): boolean;
*/
declare function setMemoryOnlyKeys(keyList: OnyxKey[]): void;

/**
* Sets the callback to be called when the clear finishes executing.
*/
declare function onClear(callback: () => void): void;

declare const Onyx: {
connect: typeof connect;
disconnect: typeof disconnect;
Expand All @@ -311,6 +317,10 @@ declare const Onyx: {
isSafeEvictionKey: typeof isSafeEvictionKey;
METHOD: typeof METHOD;
setMemoryOnlyKeys: typeof setMemoryOnlyKeys;
onClear: typeof onClear;
isClientManagerReady: typeof ActiveClientManager.isReady,
isClientTheLeader: typeof ActiveClientManager.isClientTheLeader,
subscribeToClientChange: typeof ActiveClientManager.subscribeToClientChange,
};

export default Onyx;
Expand Down
Loading

0 comments on commit d858361

Please sign in to comment.