Skip to content

Commit

Permalink
Manual synchronization mode for comparison to asynchronous VCS
Browse files Browse the repository at this point in the history
  • Loading branch information
ekuiter committed Nov 2, 2019
1 parent d8d5f60 commit 81c068f
Show file tree
Hide file tree
Showing 11 changed files with 110 additions and 47 deletions.
12 changes: 6 additions & 6 deletions client/src/components/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,28 @@ import ShortcutContainer from './ShortcutContainer';
import actions from '../store/actions';
import {StateDerivedProps, State} from '../store/types';
import logger, {setLogLevel, LogLevel} from '../helpers/logger';
import {flushMessageQueue} from '../server/messageQueue';
import {flushOutgoingMessageQueue, queueingMessageHandler} from '../server/messageQueue';
import {artifactPathToString} from '../types';
import {history, getCurrentArtifactPath} from '../router';
import {Router, Route, Switch} from 'react-router-dom';
import i18n from '../i18n';
import FeatureDiagramRouteContainer from './FeatureDiagramRouteContainer';

class AppContainer extends React.Component<StateDerivedProps> {
flushMessageQueueInterval: number;
flushOutgoingMessageQueueInterval: number;

componentDidMount() {
openWebSocket(this.props.handleMessage);
openWebSocket(queueingMessageHandler(this.props.handleMessage));

this.flushMessageQueueInterval = window.setInterval(
flushMessageQueue, this.props.settings!.intervals.flushMessageQueue);
this.flushOutgoingMessageQueueInterval = window.setInterval(
flushOutgoingMessageQueue, this.props.settings!.intervals.flushOutgoingMessageQueue);

if (this.props.settings!.developer.debug)
setLogLevel(LogLevel.info);
}

componentWillUnmount() {
window.clearInterval(this.flushMessageQueueInterval);
window.clearInterval(this.flushOutgoingMessageQueueInterval);
}

componentDidUpdate() {
Expand Down
6 changes: 5 additions & 1 deletion client/src/components/CommandBarContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ const CommandBarContainer = (props: StateDerivedProps & RouteProps) => (
commands.about(props.onShowOverlay!)
]
}
}
},
...props.featureModel
? commands.featureDiagram.manualSync(props.settings!, props.handleMessage)
: []
]}
farItems={[{
key: 'collaboratorFacepile',
Expand Down Expand Up @@ -144,6 +147,7 @@ export default withRouter(connect(
};
}),
(dispatch): StateDerivedProps => ({
handleMessage: message => dispatch(actions.server.receive(message)),
onSetFeatureDiagramLayout: payload => dispatch(actions.ui.featureDiagram.setLayout(payload)),
onSetSelectMultipleFeatures: payload => dispatch(actions.ui.featureDiagram.feature.setSelectMultiple(payload)),
onSelectAllFeatures: () => dispatch(actions.ui.featureDiagram.feature.selectAll()),
Expand Down
14 changes: 12 additions & 2 deletions client/src/components/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@
*/

import i18n from '../i18n';
import {FeatureDiagramLayoutType, OverlayType, FormatType} from '../types';
import {FeatureDiagramLayoutType, OverlayType, FormatType, Message} from '../types';
import {ContextualMenuItemType} from 'office-ui-fabric-react/lib/ContextualMenu';
import {getShortcutText} from '../shortcuts';
import {canExport} from './featureDiagramView/export';
import {OnShowOverlayFunction, OnCollapseFeaturesFunction, OnExpandFeaturesFunction, OnSetFeatureDiagramLayoutFunction, OnFitToScreenFunction, OnDeselectAllFeaturesFunction, OnCollapseFeaturesBelowFunction, OnExpandFeaturesBelowFunction, OnSetSelectMultipleFeaturesFunction, OnSelectAllFeaturesFunction, OnCollapseAllFeaturesFunction, OnExpandAllFeaturesFunction, OnRemoveFeatureFunction, OnUndoFunction, OnRedoFunction, OnCreateFeatureBelowFunction, OnCreateFeatureAboveFunction, OnRemoveFeatureSubtreeFunction, OnSetFeatureAbstractFunction, OnSetFeatureHiddenFunction, OnSetFeatureOptionalFunction, OnSetFeatureAndFunction, OnSetFeatureOrFunction, OnSetFeatureAlternativeFunction, OnSetSettingFunction} from '../store/types';
import FeatureModel from '../modeling/FeatureModel';
import {Feature} from '../modeling/types';
import {defaultSettings} from '../store/settings';
import {defaultSettings, Settings} from '../store/settings';
import {preconditions} from '../modeling/preconditions';
import logger from '../helpers/logger';
import {forceFlushMessageQueues} from '../server/messageQueue';

const exportFormatItem = (featureDiagramLayout: FeatureDiagramLayoutType,
onShowOverlay: OnShowOverlayFunction, format: FormatType) =>
Expand Down Expand Up @@ -77,6 +78,15 @@ const commands = {
onClick: onRedo
}),
featureDiagram: {
manualSync: (settings: Settings, handleMessage?: (msg: Message) => void) =>
settings.featureDiagram.manualSync
? [{
key: 'manualSync',
iconProps: {iconName: 'Sync'},
text: i18n.t('commands.featureDiagram.manualSync'),
iconOnly: true,
onClick: () => forceFlushMessageQueues(handleMessage)
}] : [],
addArtifact: (onShowOverlay: OnShowOverlayFunction) => ({
key: 'addArtifact',
text: i18n.t('commands.addArtifact'),
Expand Down
5 changes: 5 additions & 0 deletions client/src/components/overlays/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,11 @@ export default class extends React.Component<Props, State> {
}],
(votingStrategy, onlyInvolved) => this.props.onSetVotingStrategy(
{votingStrategy, onlyInvolved: onlyInvolved === 'true'}))
}, {
text: i18n.t('commandPalette.featureDiagram.manualSync'),
icon: 'Sync',
action: this.action(() => this.props.onSetSetting(
{path: 'featureDiagram.manualSync', value: (bool: boolean) => !bool}))
}, {
text: i18n.t('commandPalette.developer.debug'),
icon: 'DeveloperTools',
Expand Down
8 changes: 5 additions & 3 deletions client/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ const translationMap = {
</div>
</div>
),
hasUnflushedMessages: (numberofUnflushedMessages: number) =>
`${numberofUnflushedMessages} messages have not yet been synchronized.\nIf you proceed, these messages will be lost.`,
hasUnflushedOutgoingMessages: (numberofUnflushedOutgoingMessages: number) =>
`${numberofUnflushedOutgoingMessages} messages have not yet been synchronized.\nIf you proceed, these messages will be lost.`,
commands: {
file: 'File',
edit: 'Edit',
Expand All @@ -77,6 +77,7 @@ const translationMap = {
fitToScreen: 'Fit feature model to screen',
showConstraintView: 'Show constraint view',
splitConstraintViewHorizontally: 'Constraint view sidebar',
manualSync: 'Synchronize',
feature: {
newMenu: {
title: 'New',
Expand Down Expand Up @@ -180,7 +181,8 @@ const translationMap = {
firstVote: 'First vote wins',
plurality: 'Plurality vote',
majority: 'Majority vote',
consensus: 'Consensus (default)'
consensus: 'Consensus (default)',
manualSync: 'Toggle manual synchronization'
},
developer: {
debug: 'Developer: Toggle debug mode',
Expand Down
6 changes: 3 additions & 3 deletions client/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import actions, {Action} from './store/actions';
import {LogLevel, setLogLevel} from './helpers/logger';
import Kernel from './modeling/Kernel';
import {initialState, State} from './store/types';
import {numberofUnflushedMessages} from './server/messageQueue';
import {numberofUnflushedOutgoingMessages} from './server/messageQueue';
import i18n from './i18n';
import uuidv4 from 'uuid/v4';
import {defaultSettings} from './store/settings';
Expand Down Expand Up @@ -52,8 +52,8 @@ if (!window.name)
window.setInterval(updateLastActive, defaultSettings.intervals.lastActive);

window.addEventListener('beforeunload', (e: BeforeUnloadEvent) => {
if (numberofUnflushedMessages() > 0)
e.returnValue = i18n.getFunction('hasUnflushedMessages')(numberofUnflushedMessages());
if (numberofUnflushedOutgoingMessages() > 0)
e.returnValue = i18n.getFunction('hasUnflushedOutgoingMessages')(numberofUnflushedOutgoingMessages());
});

if (window.location.protocol !== 'http:')
Expand Down
66 changes: 48 additions & 18 deletions client/src/server/messageQueue.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import {ArtifactPath, Message} from '../types';
import {sendMessage, isSimulateOffline} from './webSocket';
import {sendMessage, isSimulateOffline, isManualSync} from './webSocket';
import logger from '../helpers/logger';

const tag = 'queue';
const messageQueue: Message[] = []; // TODO: save in localStorage
let isFlushingMessageQueue = false;
const outgoingMessageQueue: Message[] = []; // TODO: save in localStorage
const incomingMessageQueue: Message[] = [];
let isFlushingOutgoingMessageQueue = false;

export function enqueueMessage(message: Message, artifactPath?: ArtifactPath): Message {
export function enqueueOutgoingMessage(message: Message, artifactPath?: ArtifactPath): Message {
if (artifactPath)
message = {artifactPath, ...message};
messageQueue.push(message);
outgoingMessageQueue.push(message);
return message;
}

export function numberofUnflushedMessages(): number {
return messageQueue.length;
function enqueueIncomingMessage(message: Message): void {
incomingMessageQueue.push(message);
}

export async function flushMessageQueue(): Promise<void> {
if (numberofUnflushedMessages() > 0) {
export function numberofUnflushedOutgoingMessages(): number {
return outgoingMessageQueue.length;
}

export async function flushOutgoingMessageQueue(forceFlush = false): Promise<void> {
if (numberofUnflushedOutgoingMessages() > 0) {
if (!document.title.startsWith('(*) '))
document.title = '(*) ' + document.title;
}
Expand All @@ -28,30 +33,55 @@ export async function flushMessageQueue(): Promise<void> {
return;
}

if (isFlushingMessageQueue) {
if (isFlushingOutgoingMessageQueue) {
logger.warnTagged({tag}, () => 'already flushing message queue, abort');
return;
}

isFlushingMessageQueue = true;
const numberOfMessages = messageQueue.length;
while (numberofUnflushedMessages() > 0) {
if (isManualSync() && !forceFlush)
return;

isFlushingOutgoingMessageQueue = true;
const numberOfMessages = outgoingMessageQueue.length;
while (numberofUnflushedOutgoingMessages() > 0) {
try {
await sendMessage(messageQueue[0]);
await sendMessage(outgoingMessageQueue[0]);
} catch (e) {
// TODO: warn the user that the message will be sent when reconnected (maybe give an undo
// button to remove the message from the queue and undo the operation)
logger.warnTagged({tag}, () => `could not send ${messageQueue[0].type} message, abort flushing message queue`);
isFlushingMessageQueue = false;
logger.warnTagged({tag}, () => `could not send ${outgoingMessageQueue[0].type} message, abort flushing message queue`);
isFlushingOutgoingMessageQueue = false;
return;
}
messageQueue.shift();
outgoingMessageQueue.shift();
}

if (document.title.startsWith('(*) '))
document.title = document.title.substr(4);

if (numberOfMessages > 0)
logger.infoTagged({tag}, () => `successfully sent ${numberOfMessages} messages`);
isFlushingMessageQueue = false;
isFlushingOutgoingMessageQueue = false;
}

export function flushIncomingMessageQueue(handleMessage?: (msg: Message) => void, forceFlush = false): void {
if (isManualSync() && !forceFlush)
return;

while (incomingMessageQueue.length > 0) {
if (handleMessage)
handleMessage(incomingMessageQueue[0]);
incomingMessageQueue.shift();
}
}

export const queueingMessageHandler = (handleMessage?: (msg: Message) => void) =>
(message: Message): void => {
enqueueIncomingMessage(message);
flushIncomingMessageQueue(handleMessage);
};

export function forceFlushMessageQueues(handleMessage?: (msg: Message) => void): void {
flushOutgoingMessageQueue(true);
flushIncomingMessageQueue(handleMessage, true);
}
10 changes: 10 additions & 0 deletions client/src/server/webSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import {wait} from '../helpers/wait';
import {State} from '../store/types';
import Sockette from './Sockette';
import {Persistor} from 'redux-persist';
import {getCurrentFeatureModel} from '../store/selectors';

type HandleMessageFunction = (data: Message) => void;

let handleMessage: HandleMessageFunction;
const tag = 'socket';

// this is _not_ good code, but it gets the job done >_<
function getSimulateDelay() {
const state: State | undefined =
(window as any).app && (window as any).app.store && (window as any).app.store.getState();
Expand All @@ -39,6 +41,14 @@ export function isSimulateOffline() {
return state ? state.settings.developer.simulateOffline : 0;
}

export function isManualSync() {
const state: State | undefined =
(window as any).app && (window as any).app.store && (window as any).app.store.getState();
if (!state)
logger.warn(() => 'store not accessible, can not synchronize manually');
return state ? getCurrentFeatureModel(state) && state.settings.featureDiagram.manualSync : false;
}

const getWebSocket = ((): () => Promise<Sockette> => {
let promise: Promise<Sockette> | undefined;

Expand Down
10 changes: 5 additions & 5 deletions client/src/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {ThunkAction} from 'redux-thunk';
import {State} from './types';
import {Feature, PropertyType, GroupType, KernelConstraintFormula} from '../modeling/types';
import Kernel from '../modeling/Kernel';
import {enqueueMessage, flushMessageQueue} from '../server/messageQueue';
import {enqueueOutgoingMessage, flushOutgoingMessageQueue} from '../server/messageQueue';
import deferred from '../helpers/deferred';
import {getCurrentArtifactPath} from '../router';

Expand All @@ -22,8 +22,8 @@ function createMessageAction<P>(fn: (payload: P) => Message): (payload: P) => Th
return async (dispatch: Dispatch<AnyAction>, getState: () => State) => {
const state = getState(),
artifactPath = getCurrentArtifactPath(state.collaborativeSessions),
message = enqueueMessage(fn(payload), artifactPath);
deferred(flushMessageQueue)();
message = enqueueOutgoingMessage(fn(payload), artifactPath);
deferred(flushOutgoingMessageQueue)();
return dispatch(action(SERVER_SEND_MESSAGE, message));
};
};
Expand All @@ -38,8 +38,8 @@ function createOperationAction<P>(makePOSequence: (payload: P, kernel: Kernel) =
Kernel.run(state, artifactPath, kernel =>
kernel.generateOperation(makePOSequence(payload, kernel)));
const message: Message = {type: MessageType.KERNEL, message: operation};
enqueueMessage(message, artifactPath);
deferred(flushMessageQueue)();
enqueueOutgoingMessage(message, artifactPath);
deferred(flushOutgoingMessageQueue)();
return dispatch(action(KERNEL_GENERATE_OPERATION, {artifactPath, kernelFeatureModel, kernelContext}));
};
};
Expand Down
10 changes: 5 additions & 5 deletions client/src/store/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {AnyAction, Store} from 'redux';
import Kernel from '../modeling/Kernel';
import {KernelFeatureModel, isKernelConflictDescriptor} from '../modeling/types';
import {getCurrentArtifactPath, redirectToArtifactPath} from '../router';
import {enqueueMessage, flushMessageQueue} from '../server/messageQueue';
import {enqueueOutgoingMessage, flushOutgoingMessageQueue} from '../server/messageQueue';
import deferred from '../helpers/deferred';

function getNewState(state: State, ...args: any[]): State {
Expand Down Expand Up @@ -210,8 +210,8 @@ function serverReceiveReducer(state: State, action: Action): State {
const artifactPath = collaborativeSession.artifactPath;
let heartbeat;
[kernelContext, heartbeat] = Kernel.run(state, artifactPath, kernel => kernel.generateHeartbeat());
enqueueMessage({type: MessageType.KERNEL, message: heartbeat}, artifactPath);
deferred(flushMessageQueue)();
enqueueOutgoingMessage({type: MessageType.KERNEL, message: heartbeat}, artifactPath);
deferred(flushOutgoingMessageQueue)();
}
return {...collaborativeSession, kernelContext, kernelCombinedEffect};
}));
Expand Down Expand Up @@ -255,8 +255,8 @@ function serverReceiveReducer(state: State, action: Action): State {
const artifactPath = collaborativeSession.artifactPath;
let heartbeat;
[kernelContext, heartbeat] = Kernel.run(state, artifactPath, kernel => kernel.generateHeartbeat());
enqueueMessage({type: MessageType.KERNEL, message: heartbeat}, artifactPath);
deferred(flushMessageQueue)();
enqueueOutgoingMessage({type: MessageType.KERNEL, message: heartbeat}, artifactPath);
deferred(flushOutgoingMessageQueue)();
return {
...collaborativeSession,
kernelContext,
Expand Down
10 changes: 6 additions & 4 deletions client/src/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ export interface Settings {
throttleUpdate: number, // how often to reposition the overlay on zoom or pan in ms
gapSpace: number, // space between node and overlay in px
width: number // width of feature callout in px
}
},
manualSync: boolean // whether manual syncing is enabled or not
},
collaboratorFacepile: {
maxDisplayableCollaborators: number, // number of users to display before overflowing
Expand All @@ -78,7 +79,7 @@ export interface Settings {
}
},
intervals: {
flushMessageQueue: number, // how often the message queue should additionally be flushed, interval in ms
flushOutgoingMessageQueue: number, // how often the message queue should additionally be flushed, interval in ms
lastActive: number // used to guarantee that only one tab is active at once, can not be changed at runtime!
// TODO: GC, heartbeat
}
Expand Down Expand Up @@ -138,7 +139,8 @@ export const defaultSettings: Settings = {
throttleUpdate: 200,
gapSpace: 5,
width: 300
}
},
manualSync: false
},
collaboratorFacepile: {
maxDisplayableCollaborators: 3,
Expand All @@ -151,7 +153,7 @@ export const defaultSettings: Settings = {
}
},
intervals: {
flushMessageQueue: 2000,
flushOutgoingMessageQueue: 2000,
lastActive: 2000
}
};
Expand Down

0 comments on commit 81c068f

Please sign in to comment.