Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for "transactions" in Property #209

Closed
zepumph opened this issue Jan 9, 2019 · 22 comments
Closed

Add support for "transactions" in Property #209

zepumph opened this issue Jan 9, 2019 · 22 comments
Assignees

Comments

@zepumph
Copy link
Member

zepumph commented Jan 9, 2019

From https://github.com/phetsims/phet-io-wrappers/issues/229, phet-io state has need to be able to set multiple Properties atomically. It would be nice to be able to tell a Property to hold off on notifying listeners until the transaction is complete.

@zepumph
Copy link
Member Author

zepumph commented Jan 9, 2019

After discussing this with @jonathanolson @samreid @chrisklus @mbarlow12, we came up with a strategy in which you can start an arbitrary number of transactions, and listeners will only be called once all of the transactions have ended (completed).

Once _notifyListeners is called (at the end of all transactions), the "old value" will be the original value from the start of the transaction process.

@zepumph
Copy link
Member Author

zepumph commented Jan 11, 2019

What is next here? @samreid should this be reviewed by someone outside the phet-io team?

@zepumph zepumph removed their assignment Jan 11, 2019
@samreid
Copy link
Member

samreid commented Jan 11, 2019

@jonathanolson already took a quick look at it, but it would be ideal if @pixelzoom can schedule time to take a look. I'm sure he is going to have lots of good questions when he comes across it in the future, would be good to plan for it instead of having him stumble across it by accident.

The code to review is in Property.js, you can search for "transaction" or review the aforementioned commits. The unit tests are in PropertyTests.js--also a good place to see a simple sample usage.

@pixelzoom
Copy link
Contributor

I question whether "transaction" is the correct name for this feature, since it makes me think of database transactions, which is an atomic unit of work that typically involves multiple (different) actions. A Property transaction is simply a way of making multiple set calls result in one notification of observers. And it doesn't prevent any of those observers from calling get while the transaction is in progress, so it's not at all atomic.

I don't have a better name to suggestion. But regardless of what this feature is called, this last point (being able to get an intermediate value while a transaction is in progress) is a problem with the current implementation -- the value returned by get should not change until all transactions are competed. So I think that the implementation of set should be revised, and this._value should not be changed while a transaction is in progress. If I do get while a transaction is in progress, I should get the value that was in place before the startTransaction call.

And some minor suggested changes to endTransaction:

      endTransaction: function() {
-       assert && assert( this.transactionCount >= 1, 'end transaction called without corresponding startTransaction' );
+       assert && assert( this.transactionCount > 0, 'end transaction called without corresponding startTransaction' );
        this.transactionCount--;
        if ( this.transactionCount === 0 ) {
          this._notifyListeners( this.transactionOriginalValue );
+         this.transactionOriginalValue = null;
        }
      },

@pixelzoom
Copy link
Contributor

pixelzoom commented Jan 11, 2019

@samreid and I discussed this via Slack. The current solution does not address the stated goal of being "able to set multiple Properties atomically". And it results in change notifications being in the wrong order in the PhET-iO data stream.

Examples that @samreid and I discussed:

// Properties in these examples:
const a = new Property(0);
const b = new Property(0);
const c = new Property(0);
const aPlusB = new DerivedProperty( [a,b], ( a, b ) => a + b );
const aPlusC = new DerivedProperty( [a,c], ( a, c ) => a + c )

If no change to `set` is made to make it atomic, and "transaction" really means "notification":

a.startTransaction();
b.startTransaction();
a.value = 1;
b.value = 2;
b.endTransaction(); // aPlusB = 1 + 2 = 3
a.endTransaction(); // aPlusB = 1 + 2 = 3

phet-io data stream has change notifications in the wrong order:
b changed
sum changed
a changed

If `set` is made atomic:

a.startTransaction();
b.startTransaction();
a.value = 1;
b.value = 2;
b.endTransaction(); // aPlusB = 0 + 2 = 2
a.endTransaction(); // aPlusB = 1 + 2 = 3

phet-io data stream still has the original problem of reporting intermediate states:
b changed to 2
aPlusB changed 2
a changed to 1
aPlusB changed to 3

Sketch of a class that could implement atomic transactions:

class Transaction {

  // @param {Property[]} properties
 constructor( properties ) {
   this.properties = properties;
 }

  start() {
   properties.forEach( property => property.defer() ); // this.deferred = value;
  }

  end() {
   properties.forEach( property => property.refresh() ); // this._value = this.deferred; derivedProperty.refresh()
   properties.forEach( property => property.notifyListeners() ); // if different value
 }
}

const transaction = new Transaction( a, b ).start();
a.value = 1;                                                            
b.value = 2;
a.value = 5;
b.value = 3;
transaction.end();

phet-io data stream:
a changed to 5
b changed to 3
aPlusB changed 8

Example with multiple DerivedProperties, use the union of their dependencies.

const d = new DerivedProperty( [a, sum ] => a * sum )

const transaction = new Transaction( a, b ).start();
a.value = 1;                                                            
b.value = 2;
a.value = 5;
b.value = 3;
transaction.end();

phet-io data stream:
a changed to 5
b changed to 3
sum changed 8
d changed to 20

Nested transactions:

const t1 = new Transaction( a, b ).start();
a.value = 1;
b.value = 2;
const t2 = new Transaction( a, c ).start();
a.value = 5;
c.value = 6;
t2.end();  // aPlusC = 1 + 6
b.value = 3;
t1.end(); // aPlusB = 5 + 3, aPlusC = 5 + 6

@samreid
Copy link
Member

samreid commented Jan 11, 2019

For API, @pixelzoom and i preferred:

// Option 5 -- able to support other types 
const transaction = new Transaction([a,b]).start(); // chaining
a.value = 7;
transaction.end();

Here were some other considerations:

// Option 1
id = Property.startTransaction([a,b]);
a.value = 3;
Property.endTransaction(); // how does it know which transaction to end?  Is it just a stack?

// Option 1.5 BAD
Property.startTransaction([a,b]);
a.value = 3;
Property.endTransaction([a]); // how does it know which transaction to end?  Is it just a stack?

// Option 2 (*)
const transaction = Property.startTransaction([a,b]); // {Transaction} or maybe {PropertyTransaction}
a.value = 3;
transaction.end();

// Option 3
const endTransaction = Property.startTransaction([a,b]);
a.value = 3;
endTransaction();

// Option 4
const transaction = new Transaction([a,b]);
transaction.start();
a.value = 7;
transaction.end();

// Option 5 -- able to support other types WINNER!
const transaction = new Transaction([a,b]).start(); // chaining
a.value = 7;
transaction.end();

@samreid
Copy link
Member

samreid commented Jan 11, 2019

Would we ever want to "withhold notifications" from Emitter? Maybe one day, but we have no need for it at the moment. We discussed this case:

const e = new Emitter();
e.startTransaction(); // nothing happens
e.emit('hello');  // nothing happens
e.emit('bye'); // nothing happens
e.emit(123); // nothing happens
e.endTransaction(); // hello bye 123

@samreid
Copy link
Member

samreid commented Jan 12, 2019

I tried a general implementation of Transaction (not tied to Property), but it became complicated because Property would need to know how to push/pop its initial values (at the beginning of transactions) so it would know whether to send notifications or not at the end of a transaction. Supporting that for singly-nested Transactions would be easy, but pushing/popping those seems complex and like it should not be part of Property. Instead, I'll look into writing a Property-specific Transaction class that can encapsulate that logic.

UPDATE: I wasn't following the defer pattern properly. See following comments.

@samreid
Copy link
Member

samreid commented Jan 12, 2019

var a = new Property(0);
var x = new Transaction(a).start()
a.value = 7;
var y = new Transaction(a).start() // captures initial value as 7
x.end(); // a still muted
a.value = 12;
a.value = 7;
y.end(); // no notification because transaction y thinks initial value was 7
// This doesn't seem like the desired behavior.
// This indicates that the Property should store its initial value before muting.

@samreid
Copy link
Member

samreid commented Jan 12, 2019

I implemented this system as prescribed in #209 (comment) and added this test:

    const a = new Property( 0 );
    const b = new Property( 0 );
    const sum = new DerivedProperty( [ a, b ], ( a, b ) => a + b );

    const transaction = new Transaction( [ a, b ] ).start();
    const log = [];
    a.lazyLink( a => log.push( 'a changed to ' + a ) );
    b.lazyLink( b => log.push( 'b changed to ' + b ) );
    sum.lazyLink( sum => log.push( 'sum changed to ' + sum ) );

    a.value = 1;
    b.value = 2;
    a.value = 5;
    b.value = 3;
    transaction.end();

However, this results in:

["sum changed to 8", "a changed to 5", "b changed to 3"]

That is because a's first listener is the sum, so it gets called first. I tried declaring the sum after the other links:

    const a = new Property( 0 );
    const b = new Property( 0 );

    const transaction = new Transaction( [ a, b ] ).start();
    const log = [];
    a.lazyLink( a => log.push( 'a changed to ' + a ) );
    b.lazyLink( b => log.push( 'b changed to ' + b ) );

    const sum = new DerivedProperty( [ a, b ], ( a, b ) => a + b );
    sum.lazyLink( sum => log.push( 'sum changed to ' + sum ) );

    a.value = 1;
    b.value = 2;
    a.value = 5;
    b.value = 3;
    transaction.end();

but this resulted in:

["a changed to 5", "sum changed to 8", "b changed to 3"]

Maybe this is because all the listeners are downstream, and maybe I should be checking the phet-io data stream. It seems it will have the same problem because phetioStartEvent is in sequence with the notifications. If we want this sequence in the PhET-iO data stream:

["a changed to 5", "b changed to 3", "sum changed to 8", ]

we would have to add another "phase" in ending the transaction, like so:

      // End defers that we started.
      var actions = this.properties.map( e => e.popDefer() );

     // NEW PHASE that sends out phetioEmitEvent
     // ...

      // If Property value is different than when initially deferred, and Property is no longer deferred, notify
      // listeners
      actions.forEach( action => action() );

However, that seems like it will make the phetioDataStream deviate from other callback streams. I'll stash a patch here in case anybody else wants to take a look before I commit.


Index: js/Property.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- js/Property.js	(revision 78b0dfc4c54df032325c30523c8c9640635a0b8a)
+++ js/Property.js	(date 1547271324000)
@@ -111,12 +111,12 @@
     // @private whether to allow reentry of calls to set
     this.reentrant = options.reentrant;
 
-    // @private {number} - the number of transactions that are in progress. see startTransaction() for usage.
-    this.transactionCount = 0;
+    // @private {number} - the number of transactions that are in progress. see pushDefer() for usage.
+    this.deferCount = 0;
 
-    // @private {*} - when the final transaction completes, listeners are notified with the value this Property had when
-    // the first transaction began.
-    this.transactionOriginalValue = null;
+    // @private {*} - while deferred, new values neither take effect nor send notifications.  When deferrment
+    // ends, the final deferred value becomes the Property value, and, if different, notifications are sent.
+    this.deferredValue = null;
   }
 
   axon.register( 'Property', Property );
@@ -135,10 +135,9 @@
       },
 
       /**
-       * Sets the value and notifies listeners.
-       * You can also use the es5 getter (property.value) but this means is provided for inner loops
-       * or internal code that must be fast.
-       * If the value hasn't changed, this is a no-op.
+       * Sets the value and notifies listeners, unless deferred. You can also use the es5 getter (property.value) but
+       * this means is provided for inner loops or internal code that must be fast. If the value hasn't changed, this is
+       * a no-op.
        *
        * @param {*} value
        * @returns {Property} this instance, for chaining.
@@ -146,7 +145,10 @@
        */
       set: function( value ) {
         assert && Validator.validate( value, this.validatorOptions );
-        if ( !this.equalsValue( value ) ) {
+        if ( this.deferCount > 0 ) {
+          this.deferredValue = value;
+        }
+        else if ( !this.equalsValue( value ) ) {
           this.setValueAndNotifyListeners( value );
         }
         return this;
@@ -214,12 +216,13 @@
       setValueAndNotifyListeners: function( value ) {
         var oldValue = this.get();
         this._value = value;
-        if ( this.transactionCount === 0 ) {
-          this._notifyListeners( oldValue );
-        }
+        this._notifyListeners( oldValue );
       },
 
-      // @private
+      /**
+       * @param {*} oldValue
+       * @private
+       */
       _notifyListeners: function( oldValue ) {
         var self = this;
 
@@ -253,28 +256,41 @@
       },
 
       /**
-       * Notifications are suppressed when a transaction is in place. You can have an arbitrary number of transactions.
+       * When deferred, set values do not take effect or send out notifications until defer ends.
        *
        * @public
        */
-      startTransaction: function() {
-        if ( this.transactionCount === 0 ) {
-          this.transactionOriginalValue = this.value;
-        }
-        this.transactionCount++;
+      pushDefer: function() {
+        this.deferCount++;
       },
 
       /**
-       * Ends the current transaction. If the current ended transaction was the final transaction, listeners are notified.
+       * Ends the current transaction and take the final value, but without sending notifications until all other
+       * properties in the Transaction take their final value.
        *
        * @public
+       * @returns {function}
        */
-      endTransaction: function() {
-        assert && assert( this.transactionCount >= 1, 'end transaction called without corresponding startTransaction' );
-        this.transactionCount--;
-        if ( this.transactionCount === 0 ) {
-          this._notifyListeners( this.transactionOriginalValue );
+      popDefer: function() {
+        assert && assert( this.deferCount >= 1, 'popDefer without corresponding pushDefer' );
+        this.deferCount--;
+        if ( this.deferCount === 0 ) {
+          var oldValue = this._value;
+
+          // Take new value but do not notify listeners until all other Properties in this transaction also
+          // have their new values
+          this._value = this.deferredValue;
+
+          // If the value has changed, prepare to send out notifications (after all other Properties in this transaction
+          // have their final values)
+          if ( !this.equalsValue( oldValue ) ) {
+            var self = this;
+            return function() {
+              self._notifyListeners( oldValue );
+            };
+          }
         }
+        return _.noop;
       },
 
       /**
Index: js/TransactionTests.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- js/TransactionTests.js	(date 1547271204000)
+++ js/TransactionTests.js	(date 1547271204000)
@@ -0,0 +1,45 @@
+// Copyright 2017, University of Colorado Boulder
+
+/**
+ * QUnit tests for Property
+ *
+ * @author Sam Reid (PhET Interactive Simulations)
+ */
+define( function( require ) {
+  'use strict';
+
+  // modules
+  const DerivedProperty = require( 'AXON/DerivedProperty' );
+  var Property = require( 'AXON/Property' );
+  var Transaction = require( 'AXON/Transaction' );
+
+  QUnit.module( 'Transaction' );
+
+  QUnit.test( 'basic tests', function( assert ) {
+    const a = new Property( 0 );
+    const b = new Property( 0 );
+
+    const transaction = new Transaction( [ a, b ] ).start();
+    const log = [];
+    a.lazyLink( a => log.push( 'a changed to ' + a ) );
+    b.lazyLink( b => log.push( 'b changed to ' + b ) );
+
+    const sum = new DerivedProperty( [ a, b ], ( a, b ) => a + b );
+    sum.lazyLink( sum => log.push( 'sum changed to ' + sum ) );
+
+    a.value = 1;
+    b.value = 2;
+    a.value = 5;
+    b.value = 3;
+    transaction.end();
+
+    debugger;
+
+    // phet-io data stream:
+    //   a changed to 5
+    // b changed to 3
+    // sum changed 8
+    // d changed to 20
+  } );
+
+} );
\ No newline at end of file
Index: js/Transaction.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- js/Transaction.js	(date 1547270951000)
+++ js/Transaction.js	(date 1547270951000)
@@ -0,0 +1,42 @@
+// Copyright 2018, University of Colorado Boulder
+
+/**
+ * TODO: Documentation
+ *
+ * @author Sam Reid (PhET Interactive Simulations)
+ */
+define( require => {
+  'use strict';
+
+  // modules
+  const axon = require( 'AXON/axon' );
+
+  class Transaction {
+
+    /**
+     * @param {Property[]} properties
+     */
+    constructor( properties ) {
+
+      // @private
+      this.properties = properties;
+    }
+
+    start() {
+      this.properties.forEach( e => e.pushDefer() );
+      return this; // chaining so clients can call const transaction = new Transaction(...).start();
+    }
+
+    end() {
+
+      // End defers that we started.
+      var actions = this.properties.map( e => e.popDefer() );
+
+      // If Property value is different than when initially deferred, and Property is no longer deferred, notify
+      // listeners
+      actions.forEach( action => action() );
+    }
+  }
+
+  return axon.register( 'Transaction', Transaction );
+} );
\ No newline at end of file
Index: js/PropertyTests.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- js/PropertyTests.js	(revision 78b0dfc4c54df032325c30523c8c9640635a0b8a)
+++ js/PropertyTests.js	(date 1547270580000)
@@ -55,7 +55,7 @@
     assert.equal( callbacks, 0, 'should not call back to a lazy multilink' );
   } );
 
-  QUnit.test( 'Test basic transactions', function( assert ) {
+  QUnit.test( 'Test defer', function( assert ) {
     var property = new Property( 0 );
     var callbacks = 0;
     property.lazyLink( function( newValue, oldValue ) {
@@ -63,12 +63,16 @@
       assert.equal( newValue, 2, 'newValue should be the final value after the transaction' );
       assert.equal( oldValue, 0, 'oldValue should be the original value before the transaction' );
     } );
-    property.startTransaction();
+    property.pushDefer();
     property.value = 1;
     property.value = 2;
-    property.endTransaction();
-    assert.equal( callbacks, 1, 'should not update value more than once' );
-
+    assert.equal( property.value, 0, 'should have original value' );
+    var update = property.popDefer();
+    assert.equal( callbacks, 0, 'should not call back while deferred' );
+    assert.equal( property.value, 2, 'should have new value' );
+    update();
+    assert.equal( callbacks, 1, 'should have been called back after update' );
+    assert.equal( property.value, 2, 'should take final value' );
   } );
 
   // Make sure that one Property can be in a transaction while another is not.
Index: js/axon-tests.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- js/axon-tests.js	(revision 78b0dfc4c54df032325c30523c8c9640635a0b8a)
+++ js/axon-tests.js	(date 1547270684000)
@@ -18,6 +18,7 @@
   require( 'AXON/BooleanPropertyTests' );
   require( 'AXON/DerivedPropertyTests' );
   require( 'AXON/StringPropertyTests' );
+  require( 'AXON/TransactionTests' );
   require( 'AXON/ValidatorTests' );
 
   // Since our tests are loaded asynchronously, we must direct QUnit to begin the tests

@samreid
Copy link
Member

samreid commented Jan 13, 2019

If we want this sequence in the PhET-iO data stream:

Alternatively, we could add the DerivedProperty to the Transaction. Transaction could automatically sort the DerivedProperties to appear last in the list.

Even though the notification order is still incorrect in the patch above, it still seems preferable to the prior implementation because the Properties retain their old values until the transaction is complete.

@samreid
Copy link
Member

samreid commented Jan 13, 2019

Alternatively, we could add the DerivedProperty to the Transaction. Transaction could automatically sort the DerivedProperties to appear last in the list.

That will not work. After popDefer, a changes and causes sum to send notifications first, even when it is in the transaction.

@samreid
Copy link
Member

samreid commented Jan 13, 2019

This test:

  QUnit.test( 'basic tests', function( assert ) {
    assert.ok( true, 'token test' );
    const a = new NumberProperty( 0, { tandem: Tandem.generalTandem.createTandem( 'aProperty' ) } );
    const b = new NumberProperty( 0, { tandem: Tandem.generalTandem.createTandem( 'bProperty' ) } );
    const sum = new DerivedProperty( [ a, b ], ( a, b ) => a + b, {
      phetioType: DerivedPropertyIO( NumberIO ),
      tandem: Tandem.generalTandem.createTandem( 'sumProperty' )
    } );
    debugger;

    const transaction = new Transaction( [ a, b, sum ] ).start();
    const log = [];
    a.lazyLink( a => console.log( 'a changed to ' + a ) );
    b.lazyLink( b => console.log( 'b changed to ' + b ) );
    sum.lazyLink( sum => console.log( 'sum changed to ' + sum ) );

    a.value = 1;
    b.value = 2;
    a.value = 5;
    b.value = 3;
    assert.equal( log.length, 0, 'nothing should be logged yet' );
    transaction.end();

    // phet-io data stream:
    // a changed to 5
    // b changed to 3
    // sum changed 8
    // d changed to 20
  } );

Is outputting this result:

sum changed to 8
a changed to 5
0 axon.general.aProperty changed {"oldValue":0,"newValue":5}
  1 axon.general.sumProperty changed {"oldValue":0,"newValue":8}
b changed to 3
2 axon.general.bProperty changed {"oldValue":0,"newValue":3}

@samreid
Copy link
Member

samreid commented Jan 14, 2019

I don't think we will be able to use the new Transaction class to solve the problem in https://github.com/phetsims/phet-io-wrappers/issues/229, because some new Properties may be created during traversal of the set state and they will not be in the Transaction. Perhaps we can use pushDefer/popDefer though.

@pixelzoom
Copy link
Contributor

Is anyone else concerned about adding this new complexity to Property?

@samreid
Copy link
Member

samreid commented Jan 14, 2019

I'm concerned, but I don't see a preferable alternate solution. So everyone is aware of the anticipated complexity increase, here is a patch that shows proposed changes to Property for this feature:


Index: js/Property.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- js/Property.js	(revision 78b0dfc4c54df032325c30523c8c9640635a0b8a)
+++ js/Property.js	(date 1547353662000)
@@ -111,12 +111,15 @@
     // @private whether to allow reentry of calls to set
     this.reentrant = options.reentrant;
 
-    // @private {number} - the number of transactions that are in progress. see startTransaction() for usage.
-    this.transactionCount = 0;
+    // @private {*} - while deferred, new values neither take effect nor send notifications.  When deferrment
+    // ends, the final deferred value becomes the Property value, and, if different, notifications are sent.
+    this.deferredValue = null;
 
-    // @private {*} - when the final transaction completes, listeners are notified with the value this Property had when
-    // the first transaction began.
-    this.transactionOriginalValue = null;
+    // @private {boolean} whether a deferred value has been set
+    this.hasDeferredValue = false;
+
+    // @private {number} - the number of deferrments that are in progress. see pushDefer() for usage.
+    this.deferCount = 0;
   }
 
   axon.register( 'Property', Property );
@@ -135,10 +138,9 @@
       },
 
       /**
-       * Sets the value and notifies listeners.
-       * You can also use the es5 getter (property.value) but this means is provided for inner loops
-       * or internal code that must be fast.
-       * If the value hasn't changed, this is a no-op.
+       * Sets the value and notifies listeners, unless deferred. You can also use the es5 getter (property.value) but
+       * this means is provided for inner loops or internal code that must be fast. If the value hasn't changed, this is
+       * a no-op.
        *
        * @param {*} value
        * @returns {Property} this instance, for chaining.
@@ -146,7 +148,11 @@
        */
       set: function( value ) {
         assert && Validator.validate( value, this.validatorOptions );
-        if ( !this.equalsValue( value ) ) {
+        if ( this.deferCount > 0 ) {
+          this.deferredValue = value;
+          this.hasDeferredValue = true;
+        }
+        else if ( !this.equalsValue( value ) ) {
           this.setValueAndNotifyListeners( value );
         }
         return this;
@@ -214,12 +220,13 @@
       setValueAndNotifyListeners: function( value ) {
         var oldValue = this.get();
         this._value = value;
-        if ( this.transactionCount === 0 ) {
-          this._notifyListeners( oldValue );
-        }
+        this._notifyListeners( oldValue );
       },
 
-      // @private
+      /**
+       * @param {*} oldValue
+       * @private
+       */
       _notifyListeners: function( oldValue ) {
         var self = this;
 
@@ -253,28 +260,44 @@
       },
 
       /**
-       * Notifications are suppressed when a transaction is in place. You can have an arbitrary number of transactions.
+       * When deferred, set values do not take effect or send out notifications until defer ends.
        *
        * @public
        */
-      startTransaction: function() {
-        if ( this.transactionCount === 0 ) {
-          this.transactionOriginalValue = this.value;
-        }
-        this.transactionCount++;
+      pushDefer: function() {
+        this.deferCount++;
       },
 
       /**
-       * Ends the current transaction. If the current ended transaction was the final transaction, listeners are notified.
+       * Ends the current transaction and take the final value, but without sending notifications until all other
+       * properties in the Transaction take their final value.
        *
        * @public
+       * @returns {function} - action that can be used to finalize after final popDefer
        */
-      endTransaction: function() {
-        assert && assert( this.transactionCount >= 1, 'end transaction called without corresponding startTransaction' );
-        this.transactionCount--;
-        if ( this.transactionCount === 0 ) {
-          this._notifyListeners( this.transactionOriginalValue );
+      popDefer: function() {
+        assert && assert( this.deferCount >= 1, 'popDefer without corresponding pushDefer' );
+        this.deferCount--;
+        if ( this.deferCount === 0 ) {
+          var oldValue = this._value;
+
+          // Take new value but do not notify listeners until all other Properties in this transaction also
+          // have their new values
+          if ( this.hasDeferredValue ) {
+            this._value = this.deferredValue;
+            this.hasDeferredValue = false;
+          }
+
+          // If the value has changed, prepare to send out notifications (after all other Properties in this transaction
+          // have their final values)
+          if ( !this.equalsValue( oldValue ) ) {
+            var self = this;
+            return function() {
+              self._notifyListeners( oldValue );
+            };
+          }
         }
+        return _.noop;
       },
 
       /**

Update: I realized that is not a great patch, because it shows the diff between the "Property.startTransaction" and "Property.pushDefer" paradigms. But it gives the gist.

@samreid
Copy link
Member

samreid commented Jan 14, 2019

I reviewed the proposal with @zepumph @jessegreenberg @chrisklus @jbphet and @jonathanolson today and @jonathanolson pointed out this problematic case:

// Imagine 2 properties

const a = new Property();
const b = new Property();

// in some library code
a.pushDefer();


// in my code
a.pushDefer();
b.pushDefer();

a.value = 12;
b.value = 123;

actions.push(a.popDefer());
actinos.push(b.popDefer());

//actions, etc.
actions.forEach(action=>action()); // send notifictanios if changed

// Problematic because you have inconsistent state for a and b

It was proposed that we purposefully limit the number of "defers" that a Property can have to 1 to avoid this pitfall.

There was general agreement that this pattern seemed acceptable. I'll work on the fix above and some cleaning up to commit to master for further review and development.

@samreid
Copy link
Member

samreid commented Jan 14, 2019

I notified dev-public:

Significant change to Property inbound for #209. Currently only used by PhET-iO State Wrapper, but introduces new properties to Property.

I tested the following things before commit:

No issues noted. Committing.

Follow up work:

  • Improve on TransactionTests
  • Understand and maybe improve the notification and data stream message ordering
  • Change Transaction.count to a boolean
  • Code review

@samreid
Copy link
Member

samreid commented Jan 4, 2020

Transaction appears unused. Can it be deleted?

@chrisklus
Copy link
Contributor

Fine with me to delete, we can always bring it back if needed. Also, @pixelzoom expressed concern for added complexity in #209 (comment).

@samreid
Copy link
Member

samreid commented Jan 18, 2020

Using setDeferred directly and/or the solution from #276 seems more promising than Transaction, so I'll delete Transaction and its tests.

@samreid
Copy link
Member

samreid commented Jan 18, 2020

I think I'll close this issue and we can continue in #276

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants