cujojs / most

Ultra-high performance reactive programming
MIT License
3.5k stars 231 forks source link

delay() not functioning as expected. #200

Open dschalk opened 8 years ago

dschalk commented 8 years ago

I have been having trouble with postponing changes to the dom in my motorcycle.js app running at http://schalk.net:3099, repo at https://github.com/dschalk/JS-monads-part4. I haven't been able to get functions that rely on setTimeout to work at all, or to update the DOM when they are supposed to. I'll try promises next.

I am raising an issue here because, when I tried `most.delay()', every instance of its use was triggered at the time specified in the first instance. Here is the code:

 const mult2$ = mMmult.x.result.map(v => {
    mMmult.x.product2 = ret(v).x
  });

  const mult3$ = mMmult.x.result.map(v => {
    mMmult.x.product2 = ret(v)
    .bnd(add, 1000)
  }).delay(2000);

  const mult4$ = mMmult.x.result.map(v => {
    mMmult.x.product2 = ret(v)
    .bnd(add, 1000)
    .bnd(double).x
  }).delay(10000);

  const mult5$ = mMmult.x.result.map(v => {
    mMmult.x.product2 = ret(v)
    .bnd(add, 1000)
    .bnd(double)
    .bnd(add, 1).x
  }).delay(15000);

These are all merged into the stream that initiates the virtual dom. The first result displays correctly. Then, after two seconds, the result from the last stream (delay(15000)) displays. It is as though the first time interval specified is applied in all subsequent instances.

campersau commented 8 years ago

I think you have to delay first and then map:

const mult2$ = mMmult.x.result.map(v => {
    mMmult.x.product2 = ret(v).x
  });

  const mult3$ = mMmult.x.result.delay(2000).map(v => {
    mMmult.x.product2 = ret(v)
    .bnd(add, 1000)
  });

  const mult4$ = mMmult.x.result.delay(10000).map(v => {
    mMmult.x.product2 = ret(v)
    .bnd(add, 1000)
    .bnd(double).x
  });

  const mult5$ = mMmult.x.result.delay(15000).map(v => {
    mMmult.x.product2 = ret(v)
    .bnd(add, 1000)
    .bnd(double)
    .bnd(add, 1).x
  });

As the example in the README shows:

// After 4 seconds, logs 10
most.from([1, 2, 3, 4])
    .delay(1000)
    .reduce(function(result, y) {
        return result + y;
    }, 0)
    .then(function(result) {
        console.log(result);
    });
dschalk commented 8 years ago

That looks better. I think I tried doing it the right way and still didn't get a good result. Lots of late night, frustrating experimentation has receded into a distant fog. Algorithms that worked in my plain Snabbdom application all flopped.

I opted for doing things more in the Cycle/Motorcycle way. See http://schalk.net:3099, repo at https://github.com/dschalk/JS-monads-part4. The relevant code is:

  const unitDriver = function () {
    return periodic(1000, 1);  // Creates a stream of 1-1-1-1..., in one-second intervals.
  }

  const sources = {
    DOM: makeDOMDriver('#main-container'),
    WS: websocketsDriver,
    UNIT: unitDriver           // Added unitDriver to the sources object.
  }

  const unitAction$ = sources.UNIT.map(v => {  // unitDriver, cycled back around from "run".
      mMunit.ret(mMunit.x + v)  // mMunit is a monad dedicated to calling "next" with its bnd method.
      .bnd(next, 2, mMZ26)  // Releases mMZ26 (below) after two seconds.
      .bnd(next, 4, mMZ27)
      .bnd(next, 6, mMZ28)
      console.log('mMunit.x ', mMunit.x)
  })

  const mult2$ = mMmult.x.result.map(v => {
    mMmult.x.product2 = v;
    let mMtemp = new Monad(v);
    mMZ26.bnd(() => mMtemp.bnd(add, 1000).bnd(mMtemp.ret).bnd(x => mMmult.x.product2 = x));
    mMZ27.bnd(() => mMtemp.bnd(double).bnd(mMtemp.ret).bnd(x => mMmult.x.product2 = x));
    mMZ28.bnd(() => mMtemp.bnd(add, 1).bnd(x => mMmult.x.product2 = x)); 
    mMunit.ret(0);
  })

I put this comment in JS-monads-part4: "Algorithms that worked in JS-monads-part3, a plain Snabbdom application, don't work in Motorcycle.js. But Motorcycle.js is a whirling wonder, awe-inspiring to behold and to use."

nissoh commented 8 years ago

Seems like you forgot to flatten your inner stream(assuming map is related to most's operator). you can do that by using the switch operator

  const mult3$ = mMmult.x.result.map(v => {
    mMmult.x.product2 = ret(v)
    .bnd(add, 1000).delay(2000)
  }).switch();

BTW.. you might wanna look at the new :: operator https://github.com/zenparsing/es-function-bind

dschalk commented 8 years ago

I experimented some more with "dalay". Streams did not resume until after the specified milliseconds had elapsed, but then I could not depend on computation sequences to run only once. It is as though delay(n) means "continue once, or twice, or maybe more times after n milliseconds have elapsed." I have also been unable to get reliable results, or any results at all, using setInterval or setTimeout.

I am very pleased with Motorcycle, and will continue to use it. My impression at this point is that my slolution (above) is not too convoluted. Defining a driver like my unitDriver and an action stream that receives values from it is the only way to get dependable results, as far as I can tell.

My solution didn't require very many lines of code, and the driver can be re-used any time I want to delay the appearance of something in the DOM. But if there is a simpler way to reliably postpone future actions, I would much appreciate it if somebody would clue me in.

dschalk commented 8 years ago

This isn't the best place for this discussion, but I'll add what is probably my last comment here. I just want to show how the result is calculated in http://schalk.net:3099 without using MonadIter. Here it is:

  const mult3$ = mMmult.x.result.map(v => {
    mMtem.ret(v)
    mMmult.x.product3 = v;
    mMpause.ret(0);
  })

  const mult4$ = sources.UNIT.map(v => {
      mMpause.ret(mMpause.x + v)
      if(mMpause.x ===1) {
        mMtem.bnd(add, 1000).bnd(mMtem.ret).bnd(x => mMmult.x.product3 = x)
      }
      if(mMpause.x === 2) {
        mMtem.bnd(double).bnd(mMtem.ret).bnd(x => mMmult.x.product3 = x)
      }
      if(mMpause.x === 3) {
        mMtem.bnd(add, 1).bnd(x => mMmult.x.product3 = x) 
      }
    })

This uses "unitDriver", defined above. mult3$ and mult4$ are merged into the main stream that maps data into the virtual DOM description. mMmult.x.product3 is placed in a paragraph.

briancavalier commented 8 years ago

Hey @dschalk. Appreciate all the examples in this thread. Is it possible you can provide a simplified example that reproduces the problem(s) you've described? For example, you could use requirebin or something that could be pasted directly into a js file and run with node locally.

I'm quite confident in delay's behavior (the circles example is a seriously intense stress test of it), but I'd like to see if there may be an edge case we've missed. It could also be that your expectations about what delay is supposed to do don't match what it actually does. That could mean that we need to improve our documentation.

Anyway, it'd be really helpful to have a simple, runnable test case.

I've noticed that in your examples, you seem to be using shared mutable state and side effects. For example, the purpose of map is to turn events of type A into events of type B using a pure function (e.g. it should only use its input--the event--and should return something), rather than to mutate shared properties (e.g. mMult.x.product3 = x).

Using side effects in a highly asynchronous system can be very difficult to reason about. One goal of reactive programming is to forego side effects, in favor of purely transformative data flow style. That made me wonder if some of the oddities you are experience are due to using a side-effectful approach.

I hope that didn't come across as critical, but rather as just trying to understand the situation, with the goal of trying to help!

dschalk commented 8 years ago

The erratic behavior occurs only when I make a change that starts setTimeout or delay before a previous setTimeout() or delay has completed. window.clearTimeout() is ignored in my up-to-date versions of Chrome and Firefox. When I change a number in http://schalk.net:3099 using BACKSPACE and then entering a substitute number, a computation starts immediately after the backspce key is pressed and then again when the new number is entered. The base number gets 1000 added to it twice and gets doubled twice. This code works perfectly if I wait for completion after pressing the backspace key:

  const mult5$ = mMmult.x.result
  .filter(x => x !== 0).map(v => {mM27.ret(v)}).delay(1000)
  .map(() => mM27.bnd(add, 1000).bnd(mM27.ret)).delay(1000)
  .map(() => mM27.bnd(double).bnd(mM27.ret)).delay(1000)
  .map(() => mM27.bnd(add, 1).bnd(mM27.ret)).delay(1000)

If I use only single digit numbers, I don't need to wait because the filter prevents a computation from starting when a box is empty. mM27 is an instance of Monad. Using its "bnd" method with add and double does what you would expect, but they return an anonymous monad holding the computation result in the "x" attribute. "bnd(mM27.ret)" uses mM27's "ret" method to fetch the result from the anonymous monad.

clearTimeout's not working in Motorcycle doesn't seem to be relevant to 'most', although it might make it hard to fashion an "abort delay" function that works in Motorcycle. I think an "abort delay" function might be a useful addition to most. My solution using a stream of 1's to cause delays is interesting, but does seem excessively elaborate.

For a while, my monad ret method created a new monad with the same id as the calling monad. Browsers don't let you use caller, so I was using eval with the id, or window[id], to give the new monad the id of the monad that used its ret method. I could have kept the monad and substituted a new value for the old one, rather than simply mutating the old one. I don't mutate the monads, but I went back to mutating their values. I don't see what is lost by doing this. In my server, I keep state in a TMVar. The TMVar doesn't mutate, even though its value frequently changes. Javascript allows mutation of object attributes, which strikes me as being a convenient shortcut. I am pretty sure that I am confessing my ignorance, and not presenting a sound argument. I wish I had an example of mutating an attribute's causing a problem in Cycle.js or Motorcycle.js.

My problem stems from the fact that I use delay in a sequence of computations, and I can't abort an ongoing sequence when a new one starts. That causes some computation steps to occur twice. I can get the result I want, but not by using setTimeout or delay.

You certainly didn't come across as being unfairly critical. Would the immutable library that people use with React be useful in a Motorcycle application?

dschalk commented 8 years ago

The code below is now part of the app running at http://schalk.net:3099 . It consistently gives the desired results because debounce provides enough time for any ongoing computations to complete.

  const mult5$ = mMmult.x.result
  .debounce(3200).map(v => {mM27.ret(v)}).delay(1000)
  .map(() => mM27.bnd(add, 1000).bnd(mM27.ret)).delay(1000)
  .map(() => mM27.bnd(double).bnd(mM27.ret)).delay(1000)
  .map(() => mM27.bnd(add, 1).bnd(mM27.ret)).delay(1000)

I'm starting work on JS-monads-part5. It will feature immutable data structures using the Facebook immutable-js library.

briancavalier commented 8 years ago

@dschalk v0.18.2 just landed with a fix for a very subtle issue related to delay. The issue seems like it'd be rare, but it's possible you were encountering it. Maybe give the update a try and see what happens.

Would the immutable library that people use with React be useful in a Motorcycle application?

Sure. Immutable.js is a general purpose immutable data structures library, so it's quite useful in almost any context :)

dschalk commented 8 years ago

I upgraded to v0.18.2. Nothing changed. If I don't wait for an already-started computation sequence to finish, I get larger than desired results due to both computation sequences calling add and double.

I don't know if it is feasible, but a "clearWait" function at the beginning of my computation sequence would make wait more useful .

briancavalier commented 8 years ago

@dschalk It was a rare situation, so I'm not surprised nothing changed.

It's still a bit tough to understand what you're seeing without a runnable example. Any chance you could put one together?

dschalk commented 8 years ago

@briancavalier I changed the definition of Monad's ret method and added a demonstration to http://schalk.net:3099 showing that you can move back through history by selecting previous versions of a monad instance which is saved in an array every time it calls its ret method. Monad might not be familiar to you, but it is very simple. I used its ret method in a demonstration I made for you.

It is all set forth at the bottom of the page at http://schalk.net:3099. For your convenience, I will repeat the relevant definitions:

  var test$ = sources.DOM.select('input#addF').events('input');

  const testAction$ = test$.map(e => mMtest
    .ret(e.target.value*1)).delay(1000)
    .map(() => mMtest.ret(mMtest.x + 1000)).delay(1000)
    .map(() => mMtest.ret(mMtest.x * 2)).delay(1000)
    .map(() => mMtest.ret(mMtest.x + 1)).delay(1000) 

By the way, substituting debounce for delay completely solves the problem. I made the substitution in an interactive demo in http://schalk.net:9033, so you can check that out if you want to.

I don't think there is much I need to add to what I presented at the bottom of the page at http://schalk.net:9093. As I mentioned there, I got 240514 in a computation that should have produced 2003 by pressing "1" seven times in rapid succession and then rapidly pressing BACKSPACE seven times. Running simultaneous computation sequences causes the number to double and have "1" and "1000" added to it multiple times. This does not happen when I substitute debounce for delay, or in any of the other algorithms I presented.

I don't know if you would consider this behavior a bug. window.setTimeout behaves this way, but window also has a clearTimeout method, which didn't seem to work in my Motorcycle.js application. Perhaps a similar method , maybe call it clearDelay, would be a good addition to most, especially if it worked in Cycle.js and Motorcycle.js. I found that I could get the functionality I was looking for by using debounce instead of delay, so I don't have a problem.

Afterthought: Thanks to you, I re-defined Monad so that its ret method no longer causes self mutilation. The functions I use with the bnd method return new anonymous monads, so again there is no mutation of the calling monad. When game players change groups, their global Group variable gets clobbered, but I can easily cure this by putting Group in a monad and using ret whenever there is a change. My little demo app doesn't need shared data structures, so I can make it immutable without a fancy library such as immutable.js by keeping changeable values in monads and using the ret method for updates.

dschalk commented 8 years ago

Update: Nothing is being mutated in the app running online at http://schalk.net:3099.

I found an aberration in this:

  const mult5$ = mMmult.x.result
  .map(v => {mM27.ret(v)})
  .map(() => mM27.bnd(add, 1000).bnd(mM27.ret)).debounce(1000)
  .map(() => mM27.bnd(double).bnd(mM27.ret)).debounce(1000)
  .map(() => mM27.bnd(add, 1).bnd(mM27.ret)).debounce(2000)

I said it was perfect, but I was able to cause it to give a result slightly greater than what is was expected. I didn't figure out a way to reliably reproduce the aberation, although fooling around with the demo did cause it to happen three times.

I say it is an "aberration", but all I mean is that it is an aberration in my monad demo app. And by that I mean that if I re-start the three-step demo calculation while a previously-started calculation is still running, I get side effects from the previously started calculation. Another way of looking at it is to say that the side effects from a previously started sequence of calculation steps using debounce sometimes propagate, thereby skewing the outcome of the most recent sequence of calculation steps.

That sort of behavior would be a bug in a database, where a query must either run to completion or completely reverse, assuring that ongoing queries have no side effects on subsequently initiated queries. This atomic behavior in a database involves reversing the steps in a query that has been superseded, or else letting it run to completion before the next query begins. My algorithms that work, the ones using sources.UNIT, don't do either. They simply stop all previously started computation sequences; and the most recently initiated sequence is not affected by the side effects of any sequence that was in progress when the most recently sequence began. The BACK and FORWARD example might help explain this, if you are interested.

most isn't a database, so maybe sequences involving delay or debounce aren't supposed to exhibit atomic behavior; in other words, maybe it is acceptable for a previously started sequence of computation steps to affect the most recent sequence of computation steps. Perhaps my edge case strays beyond the bounds of what delay and debounce are supposed to do.

Here is one of the ways I avoided side effects from ongoing previously-initiated sequences :


  mMmult.x.addA = sources.DOM.select('input#addA').events('input');
  mMmult.x.addB = sources.DOM.select('input#addB').events('input');
  mMmult.x.result = combine((a,b) => a.target.value * b.target.value, mMmult.x.addA, mMmult.x.addB);

  const unitDriver = function () {
    return periodic(1000, 1);  // Creates a stream of 1-1-1-1..., in one-second intervals.
  }

  const sources = {
    DOM: makeDOMDriver('#main-container'),
    WS: websocketsDriver,
    UNIT: unitDriver           // Added unitDriver to the sources object.
  }

 const mult$ = mMmult.x.result.map(v => {
    mMmult2.ret(v);  // Not relevant here.
    mMtem.ret(v);
    mMtem2.ret(v);  // Not relevant here.
    mM28.ret(v);   // Not relevant here.
    mMpause.ret(0);  
    mMpause2.ret(0);   // Not relevant here.
  });

  const mult4$ = sources.UNIT.map(v => {
      mMpause.ret(mMpause.x + v)
      if(mMpause.x ===1) {
        mMtem.bnd(add, 1000).bnd(mMtem.ret)
      }
      if(mMpause.x === 2) {
        mMtem.bnd(double).bnd(mMtem.ret)
      }
      if(mMpause.x === 3) {
        mMtem.bnd(add, 1).bnd(mMtem.ret) 
      }
    })

mMtem.x is displayed in a paragraph. It is always exactly what I want, free of side effects from previously initiated sequences of computation steps.

I will leave the debounce example, along with the delay examples at the end of the file, until you say you are done with them. The delay example always propagates side effects from all previously started sequences. The faster you type in numbers, the larger the result, up to a point. I experimented with 111111111111111 and multiple backspaces. I got 1.475739525896764e+269 by holding down the 9 key for a while. There is more detailed analysis at the bottom of the page at http://schalk.net:3099.

briancavalier commented 8 years ago

@dschalk Thanks, I appreciate the effort you're putting into your app and into learning algebraic structures. FWIW, most.js Streams are a (or more properly, "have a") Functor, Applicative Functor, Monoid, and Monad per the fantasy-land specification for JS algebraic structures

There's quite a bit there on your app's page. That's cool. However, it also makes debugging extremely difficult. I think our goal needs to be to find a way to create a smallest possible case that reproduces a problem. Without doing that, it seems impossible determine whether most, motorcycle, or your own code is the source of the issue.

As a next step, can you take the last debounce example that you mentioned and try to isolate it? For example, are you able to reproduce the same issue if you remove motorcycle from the picture and implement a similar example using on most and console (instead of DOM manipulation), etc.

dschalk commented 8 years ago

I eliminated mutating code for the game TAKE BACK and FORWARD features at http://schalk.net:3099.. I don't think any mutating remains now.

I am not able to reproduce the debounce issue now, with motorcycle in the picture, so I might never see it when motorcycle is out of the picture, but I won't know that it couldn't happen again. While rapidly changing the numbers, I estimate at least 100 times, three times the result was off a little. I know it was 2 greater than expected twice and maybe all three times. I don't know what I did to produce the anomaly, and I don't know that doing exactly the same thing again would produce the unexpected result again. It could be that a previously initiated sequence performed one last computation. Simultaneous computations causes the results in the example at the bottom of the page, where delay is used instead of debounce. If a previously initiated debounce sequence remained active for a few milliseconds, it probably did so only because of motorcycle and my code. Without a complex environment such as my Motorcycle application, I think it is extremely unlikely that a previously initiated debounce sequence would ever interfere with a subsequent one. I was using Chrome. Its schedulers and optimizers might have thwarted the intended behavior of debounce under the extraordinary conditions of my stress test. If similar conditions were ever possible in production code, I would be inclined to avoid introducing additional complexity and go with something real simple, like the stream of 1's.

I doubt that it was the precise numbers I entered or deleted that caused the unexpected behavior. It doesn't make sense that only certain numbers would do it. I just now tried rapidly adding and deleting numbers and every time I got the expected result. I could use a function that runs a million computations in rapid succession comparing the debounce result with the results produced by the algorithms that have, so far, consistently produced the expected results. I could make it stop if and when the results don't match. If a discrepancy occurs, I could write a more elaborate function to record the history of everything it did up to the point of failure. I doubt that there would be any discrepancies in a simple test environment.

I will gladly create a debounce example that prints to the browser console instead of the DOM, or prints to the system console if you prefer. I don't understand what you mean by "using on most". Did you mean to write "using most"? As I explained above, I wouldn't expect to observe any anomalies in a simple test environment.

By the way, I am looking forward to studying the most code. I am somewhat familiar with Haskell Functor, Applicative Functor, Monoid, and Monad. My http://schalk.net:3099 server is built on the wai-websockets server. Game state is held in a TMVar. When it came time to segregate text messages and scoreboards into user-defined groups, adding the feature was easy. It was as though I just had to tell Haskell what I wanted. Everything is so intuitive and simple to code with pattern matching and list comprehension.

I verified that my monads obey the Haskell monad rules. I was amazed at how the simple MonadIter instances were able do what generators and promises do, only without error handling, which doesn't seem to be necessary in what I have been doing. This week I was delighted to discover that mutation can easily be avoided by putting changeable values in Monad instances and using their ret method for updating. My project has a life of its own and its evolution is more about discovery than creation. Erik Meijer explained in one of his lectures why he says the lambda calculus was discovered, not invented. I feel that I have tapped into that source of fundamental reality. I'm ready to take another look at fantasy-land.

After what I said about the debounce situation, you might be thinking that the aberrations were artifacts of motorcycle static, some noise I created, or some such thing and not a debounce flaw. If, however, you think I can help you track down something that makes debounce less than perfect, please tell me how you would like for me to proceed. Thanks.

dschalk commented 8 years ago

I tried some more, but the algorithm using debounce consistently gives the expected result. I wonder if the rare exceptions occurred when a new sequence started on the same tick when a live debounce waiting period expired, resulting in a previously scheduled computation being performed on the freshly generated number. Or something like that.