-
Notifications
You must be signed in to change notification settings - Fork 27
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
Interest #4
Comments
Hey! Great question. I still want to go forward with it at some point. The primary blocker when I stopped working on it is that I realised there are some operations that simply can't be transformed, and so I'll need to add conflict markers. For example:
.. In this case, the only sensible result from transforming those operations would be to delete both When I realised all that I put it down until I have more time to come at it fresh. If someone paid me to work on it it might be a different story. |
Why doesn't one of them win and the other get discarded, like I assume happens with object insertion conflicts? Meaning two people trying to insert the same key in an object:
Result: both users end up with (Or I may be missing something but that makes way more sense to me. By the way this project is super cool and you're super cool! |
Aw thanks! Um, its a worse than that because changes can back up.
If we make user 2's change win, what do we transform user 1's operation to? Its a red hot mess. |
Woah you responded really fast! I'm gonna submit a PR with a bunch of typo fixes to the Spec then.
Hmmmmmm. My intuition is that the
The problem here is that user 1 got rid of |
Hmmmmmm it's always been possible (that is, it's also possible in JSON0) to lose huge insertions, right? (User 1 inserts huge thing into object; User 2 replaces the object with a number.) Maybe losing mass moves isn't worse? |
In JSON0 the only operations on objects are insert and delete. You can't move something inside an object (or rename a key or anything like that). So delete vs delete always just deletes it. Insert vs insert will pick one winner, but from the point of view of the loser - well, they know the content they just tried to insert. Worst case they can re-insert it or something. And you can't have an insert operation vs a delete operation. But moves are different in two ways:
Oh and we can run into a similar problem with moves to the same location:
I'm tempted to support something like an op saying |
(But yeah you can have big overwrites like that. And they are actually super rare in practice) |
The |
Great. Well maybe we should do that then. The other problem is simply that we'll probably want to bundle the lost and found entry with some application-specific metadata (which users conflicted, author, timestamp). That information will need to be passed through somehow - but maybe I can just have transform get passed an optional options object with a |
From memory I think figuring out this behaviour is pretty much all that's left btw. The code is tight though - it's probably going to be a small change but not a simple change. |
Allowing for a conflict instead of the lost and found behavior would help this generalize out to my use case and presumably others. |
@jhurliman Please let us know about your use case and why you think it would be better to have a conflict. Also, please describe what means a conflict for you, to make sure that we are all talking about the same thing. Let's all try to make a verbosity effort to make it simpler for @josephg to make his design decisions. |
This is for a multivariate testing framework where you have one base JSON document and lots of diffs to that base document organized into separate "experiments". The goal is to be able to independently create experiments but apply multiple experiments at runtime. For many of the edits this just requires a way to cleanly merge the changes, but what if one experiment modifies a property of object EDIT: In this non-interactive context, the idea of a lost and found list doesn't make sense because 1) the changes are stored outside of the base document so nothing would be "lost" by a failed merge, and 2) there is no human in the process that would be able to pluck things out of lost and found and manually reapply them. |
huh, really? My biggest problem with that is You're welcome to assemble such emitted/trapped events into a list of lost-and-found, moved-but-failed values, but you're also welcome to just ignore them. (Contrast if a list was pushed to, if you ignore that list you get a memory leak.) |
Super against this. My fundamental understanding of OT is based on my understanding of distributed version control systems like My fundamental understanding of OT is that it guarantees there are never textual conflicts, or equivalently, that everything that would be a textual conflict is automatically resolved identically by all parties; while also promising that changes that clearly, unambiguously don't semantically conflict will definitely be resolved as expected, but this promise is "best effort": it's meant for like, two changes to totally separate parts of a document; not, two changes to the same part of a document that a human could tell what the "expected" merge of is, but the algorithm isn't sophisticated enough to. The whole reason that textual conflicts need to be resolved automatically is that lots of tiny changes are being exchanged in real-time, but conversely this means that semantic conflicts that happen can be resolved interactively in real-time; for example if two people start typing in the same place at the same time, and are dissatisfied with whose insertion was automatically chosen to be first, they can switch it around. Which is why I'm a little befuddled why this is that big of a problem in the first place. Okay, I understand now, conflicts involving a move can't be resolved by ignoring a move because other stuff may have happened in the place that the move vacated from; but then just resolve such conflicts as drops and move on, and if that's not what the user wanted, that's what Ctrl-Z is for, right? (Ctrl-Z would add back the value to the place that the move vacated from; if something else had been added there, it gets dropped, and if you want to keep both values, you gotta copy the tried-to-move-but-instead-dropped value and then Ctrl-Y to add back the something else, and past the dropped value somewhere else. Which is maybe kinda annoying but strictly better than stuff lost to insertion conflicts, which you can't even recover via Ctrl-Z because that's for your own actions not counterparties.) |
I'm imagining it being explicitly named inside each operation:
... Which is a bit wordy, but it means the OT code doesn't have to make any assumptions about how your data is structured. Of course, if you always put your lost and found data in the same place, you could just insert the lost and found rules into every operation on the client, before calling The nice thing about that is that If there's no lost&found path specified, the conflicting item gets discarded.
Nooo.... I don't like that at all. The nice thing about a lostandfound list is that the changes will resolve in the same way on every peer. So, every peer will end up with the same item in the same place in the lost and found list. The stream would have the same property - it would emit the same sequence of items. But I'm not really sure what clients should do with a stream like that - maybe if the system did first-writer-wins, the server could ignore the stream entirely and then on clients the only objects that would pop out of the stream would be items that you displaced. Then the client could re-insert the newly displaced orphan somewhere in the document. The problem is that by the time that happens the server has already accepted the operation. If the cilent sends a conflicting change then gets disconnected / crashes / closes the app, the orphaned object will be unexpectedly lost forever. So how about something like this:
|
Yes :) |
Ohhhhh I forgot that such a lost-and-found list would be shared, which is pretty different from my event emitting/trapping idea.
Yes that's what I was imagining. The client/application would be welcome to reserve (at the application layer, not the OT layer),
Agreed 👎
👍 Seems fine to me. The use case I have in mind avoids these conflicts at the application layer (moves are only between arrays), so no reserved path and the path being optional are my main concerns here, though the emitting/trapping still feels more pure to me. Question: Will the value be discarded if undoing the move doesn't conflict? I.e.:
If client 1 wins, do they end up with |
I like the approach chosen by @josephg as it is flexible and generic data structure. I think that "first-writer-wins" used in addition with the "lost-and-found" for the loser of the conflict would work fine for my use case. |
Yeah I like the emitting / trapping for that reason, but relying on the client to do something in response to a mutation confirmation is unreliable. If the system works by:
... Then the question is, what happens if client2 disconnects between steps 2 and 4? Is the document in a consistent state? Have we lost data? In any case, you can always re-create that behaviour if you want it using the conflict detection function. If you don't configure the lostandfound list, concurrent edits will be deleted. Then when the client finds out about the concurrent edits, run the conflict check in the client and emit an event stream. As for your question, those operations do conflict. By default, the resulting document would be Stuff that would conflict:
And we could also detect and report on:
Also in other news, I've gotten back to the codebase. I haven't added the blackhole detection yet, but I've fixed a bunch of bugs. The fuzzer makes it through 280 iterations before crashing now (up from 20 iterations a few days ago). |
@josephg Please let us know if we can help you with the testing or code review. I personally would like to assist, although I am not familiar with coffee-script so I may need some time to get started. |
@green-coder thanks... its all javascript now (I finished converting it a couple of days ago). But ... I'm not sure if there's room for more people to plug away at it. Its the sort of thing that takes me days to ramp up on, and I wrote it. Do you mind updating the spec based on what we've been talking about above? |
@josephg That seems a little difficult for me at the moment but I can give it a try, that seems like a good start. I suggest you to create a 'ot-json1' chatroom using https://gitter.im for discussing with people willing to contribute, and put a link to it on your readme file. |
Ohhhh right we can't rely on any one client to do anything, every client/server needs to independently arrive at the same result; but if we emitted the event on every client/server and the application pushed it into the lost-and-found list, there'd be tons of duplicates.
I know, what I meant was, undoing the move (which leaves
When client 2 and the server now sync up, they'll resolve to Maybe that's still bad because it won't be added to the lost-and-found list consistently by all clients? By the way, thanks for taking the time to answer all my questions. I'm excited that you've gotten back into the codebase! |
Wait, slow down.
... Easy. |
I don't have a good place to update the status of this project - I keep a project journal, but its not online anywhere. Anyway, good news: A week or so ago the tests passed, but the fuzzer would get through about 10 iterations before finding bad input. I've been busy fixing behaviour, and its running right now, having passed more than 400 000 iterations successfully. There might still be a bug or two, but the transform function is basically finished now. (Whew, what a ride.) The ot type itself isn't finished yet though - I also haven't implemented the conflict checker or lost and found list, but both of those should be reasonably straightforward. (The transform function has probably taken about 1-2 months of time to write over the last 3 years. I expect each of those to take about 1-2 days each. The checker itself will just internally call transform with different arguments, creating & returning a list of conflicts instead of returning the transformed object itself.) ... > 1 million iterations and still going strong ... |
Hi @josephg, I tried hard to find a way to contribute to the project and modify the Also, a few details are bothering me as I would personally do things in a different way, developing first a reference version where algorithm complexity would not matter at all, make it work in the whole system, and then optimize the algorithm. One of the difficulties you have with json1 is that you made yourself facing all the problems at the same time : usability issues, conflict resolution policies, implementation, validation, optimizations, Coffeescript<->JS waltz. I am sure that there are people like me who would love to contribute, but they also have to face all of that at the same time, that's a pretty steep learning curve. I believe that it is the reason why much of the activity happen inside the issue tracking conversation rather than within PRs. I hope you can finish the project, I am sure people will be ready when it will be the time to test it. |
Well thats what I'm doing. The code at the moment is a bit of a mess - there's a lot of code duplication and code written out explicitly that would be better off tidied away behind some simple abstractions. But the fuzzer is a great teacher - it keeps showing me new test cases I didn't consider. Many of the abstractions I have come up with have turned out to be wrong, or badly designed. I've also had to adjust some of my test cases. Once the code is working I want to do a cleanup pass - which is much easier once I know the complete set of behaviour. (As it is each new failing test case found by the fuzzer gets simplified and added to the standard set of tests) Progress update: I got compose working - it took about a day to write. Up until this point the fuzzer was only generating simple operations (usually just one edit). Now that compose is working the fuzzer is finding some new bugs in transform that I hadn't seen before.d My current sticking point is bugs of this form:
Expected result of
... Which require adding some more tracking code. But I've been pulled off this project for now to do things which result in my rent being paid. I hope to get back and finish it soon. Its so close! |
What is the status of this project as of today? Last update was 5 months ago |
Awesome :) Since you're all keen.. I want some feedback on something. Sometimes two operations try to mutually destroy one another's contents. Eg, op1 moves There's two ways transform could respond:
Note that its a pretty rare thing to happen by accident. If we throw, lots of applications won't expect an exception and that could cause problems. But silently deleting user data is generally a Really Bad Thing and in my own applications I'll probably want this behaviour. (I'm considering making transform optionally throw on all instances of deleted user data.) My question is this: I'm going to implement option 2. But is it worth also implementing option 1? Recovering here is tricky, and I'm not sure if its worth doing the work. |
I think Option 2 is correct. Nothing should delete that isn't explicitly a
delete.
…On Sat, Dec 1, 2018, 4:07 PM Seph Gentle ***@***.*** wrote:
Awesome :)
Since you're all keen.. I want some feedback on something. Sometimes two
operations try to mutually destroy one another's contents. Eg, op1 moves
doc.a -> doc.b.x and op2 moves doc.b -> doc.a.x.
There's two reasonable responses that transform could have:
- *Option 1*: Detect this and make the operations delete the contents
of both doc.a and doc.b
- *Option 2*: Detect this and throw an exception when it happens
Note that its a pretty rare thing to happen by accident. If we throw, lots
of applications won't expect an exception and that could cause problems.
But silently deleting user data is generally a Really Bad Thing and in my
own applications I'll probably want this behaviour. (I'm considering making
transform optionally throw on *all* instances of deleted user data.)
My question is this: I'm going to implement option 2. But is it worth also
implementing option 1? Recovering here is tricky, and I'm not sure if its
worth doing the work.
—
You are receiving this because you commented.
Reply to this email directly, view it on GitHub
<#4 (comment)>, or mute
the thread
<https://github.com/notifications/unsubscribe-auth/AAEkz4C_J94rDu97yzVyXGtzkdoKD6HIks5u0xmzgaJpZM4JKnB_>
.
|
Yeah, I agree, Option 2 seems to be correct. Actually, I don't quite understand why this scenario should result in a complete delete (technically, yes, but not intentionally). Apparently the authors of those operations just wanted to move their stuff to another node and created a conflict. Since with trees actual unresolvable conflicts are possible (which is not the case for OT on a string/list), an exception seems ok to me. |
OT and CRDT traditionally are conflict-free. Thats part of the point of them. But yeah; I think thats probably the wrong baggage to carry here. Mostly conflict free seems more correct. Deleting arguably makes sense in some situations - like, if I move a file into a directory at the same time as you delete the directory, its reasonable that my file gets deleted too. Although I can also see the argument that this should generate a conflict instead. And if the operations are live user interactions, deleting is not a big deal because you'll see the delete immediately. In a 3d modelling program, I add a primitive to a car and you delete the car. The primitive gets deleted too. So long as its clear to me that you deleted the car I was working on, thats not a big correctness problem. I've been thinking about the APIs internally. I've been trying to avoid transform having different modes because of the complexity burden. I think I know how I want to do it now. I think I'm going to change how the transform function itself works so it either returns Then the standard transform function will call that and throw if there are conflicts. And I can also add a no-conflict wrapper which calls transformRaw, and if there are errors it would delete everything at all the paths which conflict and try again. |
I agree with your examples, but in those cases that you mention, one operation is a true "delete", so there is no surprise and I wouldn't want a conflict or an exception here. In case of two conflicting moves, I'm not so sure I'd call that "delete"...
YES!! This is awesome. I really like this idea :) |
Yes true! The different times we get conflicts are:
I'm probably forgetting a few cases - I'll make a proper list as I write the code. There's also some things that I think shouldn't conflict. (What do you think?)
|
Hm. This one is not so obvious... What if op1 (moving But: even if both operations happen at the same time, or the other way around, don't you think that the intention of op2 is fulfilled by deleting the child I'd say it this way: op2's intention is to not have So yes, this case shouldn't conflict, but neither should op1 become a noop.
This one is easy: yes. |
I'm most of the way through implementing this now - I'm just finishing up fixing some tests. At the moment the API is looking like this:
Its a bit all or nothing like this. Given that I think most of the time you'll want to auto-recover from some conflicts but not others I might add a I've decided to make an invariant with conflicts that if The current implementation has 3 different conflicts:
These things don't currently conflict: 1. op1 moves an object that was removed by op2Eg: I'm reasonably happy with 1. @michael-brade:
We sort of do know. From op2's point of view, the document contained the 2. op1 and op2 both move the same object to different locationsEg: This is an interesting case. This is really easy for transform to detect, and there are probably cases where users will want to disallow this. Given we have all the rest of the conflict infrastructure, I'm tempted to add a conflict for this. The API I'm expecting people to use will be something like
|
This is such an important body of work thankyou. When I review the code...its mind boggling tbh to wrap your jead around. This is a clear case where typescript would add much needed self documentation, rigor / compiler checks and refactoring support |
^_^ I've thought about porting the code to typescript. I'm working on another project in typescript and I'm quite fond of typescript in general. But surprisingly, I don't think typescript would add much here. Most correctness bugs in this library are logic bugs rather than typing bugs. And I can't think of any bugs that the typescript compiler would find that won't get picked up by the now 200+ tests + fuzzer. I also don't expect to do much refactoring at this point. There'll be a cleanup pass to trim out all the debugging information and rename some variables but hopefully that should be about it. Types would definitely be useful for consumers to help create & interact with operations via cursors and other utility methods. But I'm really not sure what else they'd get us. |
Judging from the way you quoted me, I think you may have misunderstood what I meant. The intent certainly is to remove I can't actually think of a common example (which may mean that you are right), but here is a possible example: say you have an address book database or a user management database. As I said, maybe not the most realistic example... but it should explain what I meant. As for points 2 and 3, you surprised me here:
Always? I cannot even think of a single use case where it shouldn't conflict... I mean, what would the
Yup, that means we'd lose op2's data. But no surprise here for me. If you still want a conflict to be safe, then to be consistent I would also expect at least a conflict in case 1 (op2 moves part of an object that op1 removed). |
Hm, I hear what you're saying. I guess the way I see it is that if you want to save Alice's address, you should move it out of her record before deleting her information from the database. Once you delete her, the data is gone regardless of what subsequent operations are submitted. I think thats a reasonable restriction.
😂 Well, we pick one of the two move destinations in an arbitrary but consistent way and move the object there. In this case, only the op that is considered the 'left' op will have its move honored. The data you've moved hasn't been lost or anything - you can always move it back if that was the wrong choice. And you have the other operation, so you have all the information you need to figure out where it went. There is some weirdness with this behaviour if the operations are also configuring the object. Consider:
By the rule of "one of those moves wins", alice will end up either in But yeah; if its obvious to you that this should conflict I'll just add a conflict for it. Its a pretty easy thing to detect & recover from in any case. Meanwhile, I think the API is going to end up as something like this: const json1 = require('json1')
const type = json1.autoRecoverFromConflicts(conflict => {
// Automatically recover from all conflicts except blackholes (or whatever logic you want)
return (conflict.type !== json1.CONFLICT_BLACKHOLE)
})
// ...
// Ok because drop collisions are allowed by the predicate above
type.transform(['x', {i:5}], ['x', {i:6}], 'left')
// But this throws:
type.transform([['a', 'b', {p:0}], ['b', {d:0}]], [['a', {d:0}], ['b', 'a', {p:0}]], 'left')
// -> TransformConflictError type=BLACKHOLE |
Fair enough. I think I agree 😄
Cool! I think it makes sense to at least have the option. Even better if it is easy to recover from.
Great! Looks good to me! 👍 |
Small update: the code is pretty much correct now. The fuzzer gets up to about 2 million iterations before finding some invalid input. The failure case it found is legit, but we're well into the long tail of obscure bugs that (I hope) you'd have to get very unlucky to run into in practice. At this point the code is usable, although there's a chance I might change some of the output slightly. I'd like to throw a preview release up on npm so we (certainly I) can start using it and messing around with it. It'd also be nice to launch that release with a function to modify a path by an operation. Thats super useful in practice, and I'll want that almost immediately when I'm actually using the code. I also think porting it to typescript at some point is a good idea. Once its all correct I'd like to do a big cleanup pass. There's plenty of small pieces in there that could do with some spring cleaning, and moving the code to TS at the same time might be a good call. (And I'd like to port the tests away from coffeescript too!) Other changes:
I had a good look at how I can implement the For example:
... This results in a consistent output for all input, while also letting the user modify the operation and resubmit something else. If you're curious, the code itself is here. Its pretty straight forward. But the question then is, how could I modify operations to recover when both ops move the same object? The problem is that there's no safe intermediate place to move the object to. The edge cases to consider are these:
I'm sure there's a way to do it, but the answer might be that my nice neat abstraction gets broken in the process. The code currently recovers from these issues just fine - maybe I'll end up passing a whitelist of allowed mutual moves into the transform function or something. It wouldn't be the first special case. Not by a long shot. |
Great, the first part is really great :-) About the second part: I have to admit, I don't understand it completely. Two things:
|
op1 is transformed into
And that is the correct result. Can we use the same approach for mutual move conflicts? I don't think so. Consider But this doesn't work for all of the edge cases I listed above. Consider But I think thats ok. The answer is probably just to abandon this recovery trick for mutual moves, and instead detect it and throw (or whatever) before calling the internal transform function. And then if its marked as allowed, we let the existing transform code handle the complexity of resolving this sort of thing. And it is complex - for example, consider transform( Anyway, sorry that was probably a longer explanation than you expected. That probably wasn't super useful for you, but it was useful for me to talk about this stuff to get my thoughts straight. In other news:
|
Oh yes, that was super useful! This is a really good explanation. I just don't know where the formula to transform But maybe here is a typo:
I guess you meant: The desired result from Also, I don't think a conversion from json1 to json0 is neccessary. Who would want to use it anyway? Just out of curiosity: Am I right to assume that your fuzzer is using a fixed "random" sequence so that it is always reproducible? It is an amazing tool and a great way to replace monkeys having to type for 100 years or more :-D And yes, I know that feeling about yak shaving and whack-a-mole -- but I love it because it involves a limited complicated problem that fits in my head and I can spend a long time thinking about it. Much more fulfilling than being dragged in this direction and that direction just to get something sort of trivial to run. |
No thats not how transform works. Transform returns whatever op1 would be if it was applied after op2. So consider the simple case of op1: So now consider again op1:
Transform just returns what op1 would look like if it were applied after op2. If you want a combined operation, you can make it either by calling In this case:
Anyone using json0 who wants to migrate to the new json1 code. For example, everyone using sharedb. |
Ah, of course! How could I have missed that, now it all makes sense. Which really means: tough luck for your little recovery trick 😂 But really, maybe there is a way... If I understand the code correctly,
Why not fix the
No no, this time you misunderstood 😁 I meant the other way around, from new (json1) to old (json0). We need old to new to use an existing sharedb with json1, but what would be the use for new to old? You said you won't do it, but I was wondering why you even considered it... |
One more question: Semantically, what does EDIT: hm... I saw that |
Well, we don't actually want to delete anything in the output. No information is lost when we resolve this conflict. And if we lie about what op2 looks like to transform like this, it'll break other stuff. In this case, if we were dealing with a list instead of an object, any later list indexes will end up wrong in the transformed result because they wouldn't be modified by op2's insert at I could probably come up with an example test case demonstrating this, but I'm not going to.
Its a tag for breaking symmetry. Eg, imagine if two operations both insert at the same index in a list. It doesn't matter which insert ends up first in the output and which ends up second, but whichever way around they end up, all peers need to make the same decision otherwise they won't end up with the same document afterwards. Transform's caller is responsible for tagging one operation as 'right' and one as 'left' in a consistent way. In google wave and stuff I've worked on, the left op is whichever one reached the server first. In other systems I've heard about people using unique client IDs and 'left' is the op generated by the client with the lower id. It doesn't matter so long as the decision is made consistently. If all peers don't break ties the same way the system can't converge. |
I've fixed some more bugs, added an initial pass of a I'll probably close this issue soon. Normal bugs should be filed as regular issues here and we can address them as they come up. I've also updated the readme describing some of the things that still need work. It'd also be great to get some usage examples up for people to play with. |
Are you interested in consulting on some OT work? |
@EvanSchalton haha, I was wondering the same thing. What are you working on? @josephg looking for OT consulting? You may have a couple options. |
@jacwright I'm more or less building a serverless version of sharedb in typescript |
@EvanSchalton I think that's a great idea. CKEditor is providing something like that for rich text documents. A service that provided those features for text as well as objects & arrays etc. could be really cool. |
@jacwright @EvanSchalton Not looking for consulting work at the moment, unless its aligned with my current plans. But feel free to shoot me an email if you want to get in touch. (My email address is in all my git commits.) |
@jacwright yeah -- I don't like the pay-per-user model; it doesn't align well with client priorities. When it comes to collaboration tools, IMO, they're more effective the more people can collaborate, and inversely -- not effective if people aren't collaborating. I've seen too many failed pilots because the cost structure (per user) caused the piloting company to select a smaller initial user pool -- resulting in diminished returns and ultimately a decommissioning of the project. I think per-user pricing is a vestige of a bygone era -- you used to have to worry about server capacity so you effectively had to charge customers for the potential of their usage, but now we have easily scalable cloud compute -- especially with serverless architecture -- that frees me up to be more creative with my pricing strategy, unfortunately that means I also can't entertain vendors who have per user pricing models. It seems like we're on similar bents; I'd love to connect on LinkedIn -- bounce ideas off each other as we implement @josephg thanks, will do! |
@EvanSchalton I just meant a service like that would be great. Pricing
model is whatever.
…On Thu, Feb 4, 2021 at 9:41 PM Evan Schalton ***@***.***> wrote:
@jacwright <https://github.com/jacwright> yeah -- I don't like the
pay-per-user model; it doesn't align well with client priorities. When it
comes to collaboration tools, IMO, they're more effective the more people
can collaborate, and inversely -- not effective if people aren't
collaborating. I've seen too many failed pilots because the cost structure
(per user) caused the piloting company to select a smaller initial user
pool -- resulting in diminished returns and ultimately a decommissioning of
the project.
I think per-user pricing is a vestige of a bygone era -- you used to have
to worry about server capacity so you effectively had to charge customers
for the potential of their usage, but now we have easily scalable cloud
compute -- especially with serverless architecture -- that frees me up to
be more creative with my pricing strategy, unfortunately that means I also
can't entertain vendors who have per user pricing models.
It seems like we're on similar bents; I'd love to connect on LinkedIn
<https://www.linkedin.com/in/evanschalton/> -- bounce ideas off each
other as we implement
@josephg <https://github.com/josephg> thanks, will do!
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#4 (comment)>, or
unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAA5LZX4GWT64RPLLOEK733S5NZHHANCNFSM4CJKOB7Q>
.
|
I've been following this project since last year and am really excited about the possibilities this brings to general purpose OT.
It seems that there hasn't been much activity on this and I was wondering if you are still working on it in private or if you have lost interest and moved on to other things?
The text was updated successfully, but these errors were encountered: