Tuesday, February 6, 2024

Haxe and Statecharts: The Evolution of Lightstreamer SDKs

In the digital age, where information moves faster than ever, real-time data streaming has become a cornerstone of modern software development. From financial markets to social media feeds, the demand for live, up-to-the-second data is ubiquitous. Enter Lightstreamer: a robust server engineered to meet this demand by delivering real-time data to any Internet-connected device, anywhere in the world. Lightstreamer excels in situations that require the rapid transmission of live data, such as trading platforms, multiplayer games, and live broadcasting services, making it an invaluable tool for developers across a spectrum of industries.


The Challenge: Keeping Up with Multiple Client SDKs

One of the most significant challenges in developing and maintaining real-time streaming solutions is ensuring consistent performance and functionality across various client environments. With a plethora of devices and programming languages, creating and updating SDKs for each platform can quickly become a Sisyphean task. Disparities between SDKs can lead to increased development time, higher maintenance costs, and a fragmented developer experience. This challenge is not unique to Lightstreamer but is a common pain point in the software development community.

In the past, we had a different code base for each of our Client SDKs: one for JavaScript, another for Java, and so on. Even though they were similar, you can imagine the amount of work that was required to port a patch or a new feature to every platform. Moreover, even though the basic data structures were the same in each SDK, their specific implementations caused various inconsistencies. For instance, an index out of bounds throws an exception in Java, but fails silently in JavaScript. Additionally, since there was no specification of what a compliant Lightstreamer Client should be, the behavior of one SDK could differ slightly from the behavior of another. Furthermore, every time we needed to support a new platform, we had to rewrite an SDK client from scratch.

We needed to find a solution for all that mess, and we decided that the solution should be based on two cornerstones:

  1. The amount of manually-written platform-specific code should be reduced to the minimum.
  2. The specification of the Client behaviour should be written in a more abstract notation than code.

To address these requirements, we chose two means: the Haxe programming language and statechart diagrams.

The Power of Haxe

Haxe is a high-level, open source, cross-platform programming language and compiler. It can produce applications and source code for many different platforms from one code-base.

Haxe can compile to JavaScript, C++, Java, JVM, PHP, C#, Python, Lua, and other targets, and also run in interpreted mode or on its own virtual machine. This means that you can write your code once in Haxe, and then compile it to different platforms. Haxe also has a rich and cross-platform standard library, which covers common data types, collections, math, file system, network, and more. It also has platform-specific APIs for each compiler target, which allows you to access the native features of each platform.

This capability is particularly beneficial for Lightstreamer's SDKs, which must operate seamlessly on a wide range of devices and operating systems. By using Haxe, Lightstreamer can ensure that all its SDKs maintain consistent functionality and performance, regardless of the target platform.

Haxe has an ECMAScript-oriented syntax, which means it looks similar to JavaScript. However, it supports multiple programming paradigms, such as object-oriented, functional, and generic programming. Haxe has a type system that supports static and dynamic typing, type inference, null safety, and it has a powerful macro system, which allows code generation and transformation at compile time.

Haxe is used for various domains, such as games, web, mobile, desktop, command-line, and cross-platform APIs. Some of the popular frameworks and libraries that use Haxe are Kha, OpenFL, Heaps.io, HaxeFlixel, and Nape.

Simplifying Complexity with Statecharts

A statechart is a diagram that shows the behavior of a system using states and transitions. A statechart is useful for modelling the dynamic aspects of a system, such as how a system responds to events and changes its state.

A statechart consists of the following parts:

States: A state is a condition, mode or situation that a system can be in. A state is represented by a rounded rectangle with the name of the state inside. A state can have substates, which are states that are nested within another state, and orthogonal regions, that run simultaneously and independently. A substate inherits the characteristics of its parent state, such as actions. A state can be an initial state, which is the first state that a system enters, or a final state, which is the last state that a system reaches. An initial state is represented by a solid circle, and a final state is represented by a solid circle inside another circle. An orthogonal region is useful for modeling parallel or concurrent behavior, and it is represented by a dashed line that divides a state into two or more sections. Each section has its own initial and final states, and transitions between states.

Transitions: A transition is a change of state that occurs when an event happens or a condition is met. A transition is represented by a solid arrow that connects two states. A transition can have a trigger, which is the event or condition that causes the transition, a guard, which is a boolean expression that must be true for the transition to occur, and an effect, which is an action that occurs as a result of the transition. A trigger, a guard, and an effect are written on the transition arrow as follows: trigger [guard] / effect.

Actions: An action is an activity that a system performs in response to an event in a statechart. An action can describe what the system does, how it changes its variables, or how it affects other objects or systems. There are three types of actions that can occur in a statechart:

  • Entry actions, that are executed when the system enters a state. 
  • Exit actions, that are executed when the system exits a state. 
  • Transition actions, that are executed when a transition occurs. 

Actions are usually written on the state or the transition arrows using a textual or a formalised notation.

As an example of statechart, consider the behavior of a simple lamp. The lamp has two states: On and Off. The initial state is Off. The transition from Off to On is triggered by the event Switch On, and has an effect of setting the variable power to true. The transition from On to Off is triggered by the event Switch Off, and has an effect of setting power to false. The state On has an entry action of turning on the light, and an exit action of turning off the light.



Unified Client Model (UCM)

Central to Lightstreamer's approach is the Unified Client Model (UCM), which abstracts the client-side behavior to ensure a consistent development and user experience across all platforms. The UCM serves as a blueprint for SDK behavior, significantly reducing the time and effort required to implement and maintain SDKs. This model not only streamlines development but also facilitates easier updates and enhancements to the Lightstreamer platform.

The Unified Client Model (UCM) is an abstract model of the Lightstreamer Client library that remains independent of the target platform. It is based on statecharts and is implemented in Haxe. The UCM allows developers to use the same programming model and interface when using a Lightstreamer Client library for different client platforms, such as web, mobile and desktop. The UCM also ensures consistent behaviour and functionality across different platforms, while respecting the conventions, styles, and best practices of each platform.

To have a taste of the UCM, the following excerpt illustrates the process of establishing a WebSocket connection. The diagram shows several steps involved in this process: 

  • opening a connection, 
  • waiting for the connection to be ready (ws.open event), 
  • sending a special message to the Lightstreamer server to test the connection, 
  • waiting for the server's response (WSOK event), 
  • sending a request to create a new session, 
  • waiting for the server to create the session (CONOK event), etc. 

However, many things can go wrong during this process: the connection can fail (transport.error event), take too long (transport.timeout event), or be disconnected by the user (ext.disconnect event).

The UCM must handle any of these events so that the Client library can behave consistently across all target platforms supported by Haxe.




Optimizations

The Haxe standard library and the libraries developed by the Haxe community are useful for saving time and effort, as it allows Haxe users to reuse existing code instead of writing their own. However, such libraries are not always the best option for achieving high performance, as they may not be optimised for specific use cases, platforms, or requirements. Therefore, Haxe users may need to optimise their code manually by implementing custom data structures or algorithms that are tailored for the problem, or using third-party libraries that offer better performance than the standard library, or profiling and benchmarking the code to identify and eliminate performance bottlenecks.

For instance, we needed to use an asynchronous task executor to improve the performance of the Client SDK. We initially used a third-party Haxe library that provided a cross-platform task executor based on thread pools, but we encountered some problems with it. The library's thread pool was not optimized for each platform, and it had some limitations that affected the functionality and reliability of our application. Therefore, we decided to replace the implementation provided by the third-party library with a custom solution that leveraged the task executors provided by the platform native libraries. This way, we could take advantage of the native features and performance of each platform, and avoid the issues and overhead of the third-party library.

We used the Haxe powerful conditional compilation features to implement our custom solution. Conditional compilation is a feature that allows us to write code that is only compiled and executed if certain conditions are met, such as the target platform, the compiler flag, or the value of a constant. We used conditional compilation to generate different code for each platform, using the native task executor of that platform. For example, if the target platform was Java, the Haxe compiler generated code that used the java.util.concurrent package, which provides a high-performance thread pool implementation.

To evaluate the performance of the client libraries, we created a specific test case. The test consisted of subscribing to 27000 items, which produced a total flow of 9000 updates/s. In order for the new implementation to sustain this load over time, it was necessary that each batch was completely processed in a time not exceeding 1 second, that is, before the next batch arrived. Then, we measured the execution times of our application on different platforms, and we found that our solution was significantly faster than the previous one.

The table shows the performance of different versions of the client library, measured by two metrics: 

  • Ramp-up Time: the time required for the client to complete 27000 subscriptions and receive the first update. The lower the Ramp-up, the faster the client can start receiving updates.
  • Avg Time: the average time required to process a whole batch of 9000 updates. The lower the Avg Time, the more efficient the client can handle the updates.


Platform

Ramp-up [seconds]

Avg Time [seconds]

Java (unoptimized)

3.90

0.455

Java (optimized)

1.73 (-56%)

0.042 (-91%)

Python (unoptimized)

69

12

Python (optimized)

2.14 (-97%)

0.88 (-93%)

.NET (unoptimized)

120

13

.NET (optimized)

1.95 (-98%)

0.16 (-99%)

Javascript (unoptimized)

24.26

3.27

Javascript (optimized)

3.41 (-86%)

0.16 (-95%)


Conclusion

In this article, we show how we reengineered one of our main products to achieve shorter development times and more robust software. The keys to success has been the use of the cross-platform compiler Haxe and the specification of an abstract model based on statecharts. By using Haxe, we have saved time and resources, as we have written our code once and compiled it to different platforms. By using statecharts, we have avoided ambiguity, confusion, and errors in the implementation process, as we have clearly defined the software's behavior and expectations. Finally, the process of developing a cross-platform software was flexible enough that we could leverage platform-specific strengths when necessary. This means that we could use the features and capabilities of each platform that were not available or suitable for other platforms, and optimize the performance and functionality of our software for each platform. The journey of continuous improvement and innovation is far from over, and the future of Lightstreamer promises even more advancements in the realm of real-time data streaming.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.