Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Queue and cancel events using idiomatic redux observable

Given the example of a download manager. There can be any number of active downloads.

Actions can be dispatched to start, stop, mark completion and mark the download progress of any particular download.

const START_DOWNLOAD = "START_DOWNLOAD";
const startDownload = payload => ({ type: START_DOWNLOAD, payload });

const DOWNLOAD_PROGRESS = "DOWNLOAD_PROGRESS";
const downloadProgress = payload => ({ type: DOWNLOAD_PROGRESS, payload });

const STOP_DOWNLOAD = "STOP_DOWNLOAD";
const stopDownload = payload => ({ type: STOP_DOWNLOAD, payload });

const COMPLETE_DOWNLOAD = "COMPLETE_DOWNLOAD";
const completeDownload = payload => ({ type: COMPLETE_DOWNLOAD payload });

These actions will contain an id to identify the download and can modify the redux state using the following reducer:

const downloadReducer = (state = initialState, action) => {
  switch (action.type) {
    case STOP_DOWNLOAD:
      return {
        ...state,
        [action.payload.id]: {
          state: "IDLE",
        },
      };

    case START_DOWNLOAD:
      return {
        ...state,
        [action.payload.id]: {
          state: "IN_PROGRESS",
          progress: 0,
        },
      };

    case DOWNLOAD_PROGRESS:
      return {
        ...state,
        [action.payload.id]: {
          state: "IN_PROGRESS",
          progress: action.payload.progress,
        },
      };

    case COMPLETE_DOWNLOAD:
      return {
        ...state,
        [action.payload.id]: {
          state: "DONE",
          progress: 100,
        },
      };

    default:
      return state;
  }
};

Now comes the issue on how to manage the async dispatch of these actions using redux observable.

For example we could do something like this:

const downloadEpic = action$ =>
  action$.ofType(START_DOWNLOAD).mergeMap(action =>
    downloader
    .takeUntil(
      action$.filter(
        stop =>
        stop.type === STOP_DOWNLOAD &&
        stop.payload.id === action.payload.id,
      ),
    )
    .map(progress => {
      if (progress === 100) {
        return completeDownload({
          id: action.payload.id
        });
      } else {
        return downloadProgress({
          id: action.payload.id,
          progress
        });
      }
    }),
  );

This works. However, what if we want to limit the number of active downloads that are allowed. We can replace mergeMap with concatMap to only allow one active download at a time. Or we can supply the concurrent parameter to mergeMap and specify exactly how many executions of the inner downloader observable we want to allow at once.

However, this comes with the problem that we now cannot stop queued downloads.

I have created a complete working example that you can try here.

How can you limit and queue downloads using rxjs and redux observable in the most idiomatic way possible?

like image 368
Eamonn Avatar asked Dec 07 '17 04:12

Eamonn


1 Answers

I've been eyeing this one for a couple days but haven't had the time to give you a solid answer with complete code because it's relatively involved. So instead I'll just give you the tl;dr version that's better than nothing I hope :)


My gut tells me that I would have the UI dispatch an action that represents an attempt to download rather than a true guarantee. e.g. ATTEMPT_DOWNLOAD. An epic would listen for this action and check if the current number of active downloads exceeds is >= the max and if so, would emit an action to enqueue the download instead of starting it.

Your reducers would store the download IDs of those active and download IDs of those queued up. e.g.

{ active: ['123', '456', ...etc], queued: ['789'] }

Which you would use for both keeping track of active/queued specifically but also to know the counts of them active.length, queued.length

When a download completes an epic somewhere would check if there are any queued up downloads and if yes, dequeue one. Exactly how you do that is mostly personal preference. e.g. if the epic emits DEQUEUE_DOWNLOAD or whatever.

If a cancellation action is dispatched your reducers would need to look in both active and queued and remove the ID if it exists in there. If it was in fact active and not just queued then the epic which is handling the download would be listening for the cancellation action and it would stop it and do the same dequeue check as above.


This is a bit hand-wavy, but the big takeaways are these:

  • Your UI components shouldn't know how or when things will be queued before it attempts, though it can see after the fact by looking at the redux state (e.g. to show "download queued" or whatever)
  • Careful not to duplicate the status of a download in multiple places. e.g. if it's active, only have one single source of truth in your store that says it is
  • epics run after the reducers, use this to your advantage.
  • There will probably be an epic who's only job is to listen for true download requests and perform it, without knowing anything about queueing or stuff like that. This epic will then be reused by multiple other epics. Either by calling it directly as a function or by emitting an action like your real START_DOWNLOAD

It's not always clear where business logic should lie. e.g. should the reducers just be mostly getters/setters or should they have more opinionated logic and make decisions? There aren't a lot of rules in these cases. Just try to be consistent.

btw this is entirely from my gut. I might have found (or you might still find) that in practice this isn't a good solution. Just giving my initial thoughts!

like image 57
jayphelps Avatar answered Sep 30 '22 11:09

jayphelps