0

Background

I am using NestJS and the Observable<AxiosResponse> pattern with the HttpModule to "observe" and eventually forward values returned by a JSON-RPC server, in this case a Blockchain node.

In case Blockchains are unfamiliar, they can be thought of as a linked list, where each new element in the list points to the previous element, something like this:

let blockchain = [
  [0,{value: 'something', previous: None}], 
  [1,{value: 'somethingelse', previous: 0}], ...
  [N,{value: 'somethingsomething', previous: N-1}]
]

Sometimes a "fork" can happen in a blockchain system. It would look something like the below tree:

[A] <-- [B]<--- [C]
         |
         --- [C'] <--- [D] <--- [E] <--- ... and so on

Problem

If my NestJS application gets Block [A] at time 0, block [B] at time 1 and block [C] at time 2, but suddenly, at time 3 I get [E], I would not get block [D] and [C']. This means that I would be unable to inspect the values in these two missing blocks.

Logic

Because all blocks have a pointer to a previous block, I do have the ability to retrieve block [D] by simply passing the pointer to it from block [E]. Similarly, from block [D] I could subsequently get block [C']. Because [C'] has a pointer to [B] I would have successfully retrieved all missing blocks.

I am quite new to Observables so I am not entirely sure of how I can backtrack recursively when I use the NestJS HttpModule in this way:

export class BlockchainService {
    private url: string;
    private top?: number;

    constructor(private httpService: HttpService,
                private config: ConfigService) {
        this.url = this.config.get<string>('my_blockchain_url')
    }

    getBestBlockHash(): Observable<AxiosResponse<any>> {
        return this.httpService.post(this.url, { 
            "method" : "getbestblockhash" 
        })
    }

    getBestBlock(): Observable<AxiosResponse<any>> {
        return this.getBestBlockHash().pipe(
            mergeMap((hash) => this.getBlock(hash.data.result))
        )
    }

    getBlock(hash: string): Observable<AxiosResponse<any>> {
        return this.httpService.post(this.url, {
            "method" : "getblock",
            "params" : {
                "blockhash" : hash
            }
        })
}

Attempt 1

Because the Observable holds the block data, I cannot evaluate whether to backtrack, or not without subscribe to it, or pipe it.

Using Mrk Sef's proposal below and the iif() operator, seems to take me further since I can pass the getBestBlock() Observable as a parameter to a checkBackTrack function leveraging iif() as follows:

    checkBackTrack(obs: Observable<AxiosResponse<any>>): Observable<AxiosResponse<any>> {
        let diff: number
        let previoushash: string
        console.log(diff, previoushash)

        obs.pipe(tap(block => {
            diff = this.top - block.data.result.height
            previoushash = block.data.result.previoushash
        }))
        console.log(diff, previoushash)

        const backTrackNeeded = iif( 
            () => diff > 0, 
            this.backTrack(diff, previoushash),
            obs 
        )
        return backTrackNeeded;
    }

where the backTrakc function looks like:

backTrack(n: number, previoushash: string): Observable<AxiosResponse<any>> {
        return (n < 0) ? EMPTY : this.getBlock(previoushash).pipe(
            switchMap(previousBlock => this.backTrack(n-1, previousBlock.data.result.previousblockhash)),
        )
    }

allows me to do the following: this.checkBackTrack(this.getBestBlock()).

However, I am unable to define diff and previoushash in the checkBackTrack function... Also, this introduces side effects, which I do not want.

  • So the short answer is that the tap operator is synchronous. It will return and the observable will continue (emit - complete - error) without regard for anything synchronous done within it (you're basically ignorimg RxJS, so you're on your own. – Mrk Sef Mar 19 '22 at 21:02
  • try look into 1expand1 or 1mergeScan` – Fan Cheung Mar 21 '22 at 02:32
  • @MrkSef I do not agree that I am ignoring RxJS in this case. It is simply a non-trivial thing I am trying to do. Have you ever come across such a use case in the documentation? I don't think so. If you look closely at the problem, you would realise that I am attempting to backtrack missing data inside an Observable pipe, and for M missing data elements, I need 5 backtracking requests. In my opinion, there should be a technique to do this, since otherwise, it would imply RxJS HTTP response Observables could not return a reliable stream of data. – Mattia Bradascio Mar 21 '22 at 14:45
  • RxJS has operators to manage retrying requests, but they'll never work (in a straight-forward way) inside a tap operator for the reason already mentioned - you can not do an asynchronous workflow within a synchronous context (like the tap operator). – Mrk Sef Mar 21 '22 at 17:13
  • Thanks for highlighting that this won't work with the tap operator. I am by no means tied to relying upon it. As mentioned in my post, I am more than happy to consider alternative approaches, but can't find any relevant documentation as to where to look. NestJS is quite a new framework after all. :) – Mattia Bradascio Mar 21 '22 at 17:29
  • Your question includes a lot of info on what you've tried so far, but very little on what you're trying to accomplish in the end. If you update your question with some higher level view? What are the requirements you're trying to meet? What are the restrictions? Is there a reason you don't want to tranform the source observable? Do you need missed lines in a separate array (instead of a merged/separate observable?) Do you only want to retry for missed lines once? If not, how many times? With a delay? Etc? – Mrk Sef Mar 21 '22 at 20:05
  • I would say it is pretty straightforward to see what I am looking for. I ask the following: "How should I make sure each M empty line is backfilled?" That should be clear enough. – Mattia Bradascio Mar 22 '22 at 08:02
  • @MrkSef on second thoughts, since you asked for more details, it implies it wasn't as clear in the first place. So, I have added an edit. Hopefully that makes it crystal clear what I am looking for. – Mattia Bradascio Mar 22 '22 at 08:15
  • Sorry. Seems just as clear as before. (Not very) https://meta.stackexchange.com/questions/66377/what-is-the-xy-problem – Mrk Sef Mar 22 '22 at 13:45

1 Answers1

0

Toward a solution

"How should I make sure each M empty line is backfilled?"

What this means isn't very clear, but we might be in a position where you're not sure how to clarify further. I'm not sure this will answer your question, but perhaps it will help you clarify further.

Some code

Here is how I might ensure "missing" values are merged into a stream. This is meant to be a paired down implementation (for example, it assumes index is monotonically increasing and will fail otherwise) for the sake of simplicity.

interface Message {
  index: number,
  value: string
}

// simulate an http response that takes 1 second to return
function httpBackupMessage(i: number): Observable<Message> {
  return timer(1000).pipe(
    map(() => ({ index: i, value: "backup something" }))
  );
}

// An observable of messages, but it's missing messages with
// index 2 & 3
const sparseMessage$: Observable<Message> = of({
  index: 0,
  value: "something"
}, {
  index: 1,
  value: "something"
}, {
  index: 4,
  value: "something"
}, {
  index: 5,
  value: "something"
});

// An observable of messages with missing messages retrieved from
// httpBackupMessage
const denseMessage$ = sparseMessage$.pipe(
  // Dummy value is ignored, but tells out logic below that index 
  // 0 (-1 + 1) is the first index.
  startWith({ index: -1, value: "dummy value" } as Message),
  pairwise(),
  concatMap(([prev, curr]) => 
    range(prev.index + 1, curr.index - (prev.index + 1)).pipe(
      concatMap(httpBackupMessage),
      endWith(curr)
    )
  )
);

// Start the observable and print the messages to the console
denseMessage$.subscribe(console.log);

As you can see, sparseMessage$ is missing a message with index 2 & 3. Below is what is emitted by denseMessage$:

{ index: 0, value: 'something' }
{ index: 1, value: 'something' }
{ index: 2, value: 'backup something' }
{ index: 3, value: 'backup something' }
{ index: 4, value: 'something' }
{ index: 5, value: 'something' }

Some Notes

  1. This creates a new observable that transforms the original observable with the missing messages. It doesn't create a separate observable or array (both are possible, and not too dissimilar to what's done here).

  2. This is emulating a sort of middle-ware with some business logic for missing values. And yet ...

Now, say my httpService.post() request gets delayed for some reason and the JSON-RPC response I get back has returned index: N + M

this makes it sound like you're worried about the source timing out or emitting out of order. Of course it's possible to band-aid fix this in a middle-ware layer (and in an old legacy system this might be the only way), but its best to avoid this sort of design wherever possible.

  1. This solution is the shape of a solution without much extra (nowhere near production level code). It does no error handling. It doesn't ensure indices are monotonically increasing, etc. It is not meant to be a robust solution. Fortunately, many of those issues are addressed in the documentation/answered in other questions here on stackoverflow :)

  2. Feel free to modify/clarify your question. If you have separate questions or you want to re-frame this question entirely, it's best to just open a new question.

Update: Some more code

I see on revision that perhaps you're asking about how to navigate an asynchronous linked list. This is more interesting because each next value depends on the previous one (You can't index into a linked list, you must follow the links).

Here's an example of how you might follow an async linked list back 4 nodes and then emit them in the correct order.

// This an be any sort of resource, we'll use numbers
type Link = number

// Entries can be thought of as nodes in a linked list
interface DataEntry {
  prev: Link;
  value: string;
}

// Simulate Data stored on a server, access only allowed via getData
const serverData = new Map<Link, DataEntry>([
  [ 4153, {
    prev: 4121,
    value: 'Something 5',
  }], [ 4273, {
    prev: 4153,
    value: 'Something 6',
  }], [ 4291, {
    prev: 4273,
    value: 'Something 7',
  }], [ 4300, {
    prev: 4291,
    value: 'Something 8',
  }]
]);

// Simulate a call to the server. Each call takes 50ms
function getData(pointer: Link): Observable<DataEntry> {
  return timer(50).pipe(map(() => serverData.get(pointer)));
}

// Navigate backwards from start, n times. Buffer all values and emit
// in reverse order (The value n steps back is emitted first)
function backtrackLinks(start: DataEntry, n: number): Observable<DataEntry> {
  return n < 0 ? EMPTY : getData(start.prev).pipe(
    switchMap(previous => backtrackLinks(previous, n - 1)),
    endWith(start)
  );
}

// Pretend we've aquired a node through some earlier process
const nodeNine = {prev: 4300, value: 'Something 9'}
// An example of running the `backtrackLinks` observable and 
// printing the nodes to the console
backtrackLinks(nodeNine , 4).subscribe(console.log);

The output:

{prev: 4121, value: "Something 5"}
{prev: 4153, value: "Something 6"}
{prev: 4273, value: "Something 7"}
{prev: 4291, value: "Something 8"}
{prev: 4300, value: "Something 9"}

You can the use all of the usual RxJS suspects to create/merge this with other streams (That's where I imagine you'll acquire a start node from which to backtrack).

In the previous section, I used range, concatMap, & endWith but here you could replace that with backtrackLinks This embeds all that logic in one function since backtrackLinks(value, 0) equals of(value).

Mrk Sef
  • 7,557
  • 1
  • 9
  • 21
  • Thanks for being helpful. Regarding notes point 2. I am focusing on consensus protocols here, and wanted to create a generic question which is probably why it comes across quite unclear. Forks can occur in a blockchain protocol. A service subscribing to blocks from a node can end up with a new block having a block height that is greater than its latest local block height. The service subscribing to blocks would then need to re-discover the missing blocks by backtracking from the latest block. I will need to test this before approving as a valid answer. – Mattia Bradascio Mar 22 '22 at 16:49
  • @MattiaBradascio I see now that perhaps you'll need to navigate what is effectively a linked list asynchronously. You can do this with a 3-line function. I've updated my answer with a bunch of code that should run on it's own (so you can play with it). I've included how you might use both parts of this answer (as they address separate issues) together. – Mrk Sef Mar 22 '22 at 18:38
  • You are right in that this is a linked list and that I may only go backwards. Whereas it is great that you have a working solution for the simulated scenario, my scenario is a bit more complicated since I do not know `n` in advance. It can only be determined once I have received a "new" `Observable` – Mattia Bradascio Mar 23 '22 at 11:22
  • @MattiaBradascio It's not clear to me how that is any different. How/why does it matter when you know `n`? It's a recursive function, you can give it any base-case you like or delay giving it an `n` for as long as you need. That's the point of observables, they compose really well. :) – Mrk Sef Mar 23 '22 at 12:23
  • Was just about to say that it probably isn't a very big difference from a logical point of view, but from an implementation point of view it is quite hard for me to test that the logic works in practice because I do not know the size of `n` until a new `Observable` arrives, and even when that new `Observable` does arrive, I cannot easily store/update `n` without any side effects :) – Mattia Bradascio Mar 23 '22 at 13:07
  • @MattiaBradascio What? Of course you can do both those things. I'm not sure what you're stuck on. As far as I can tell, it's all laid out above? Maybe try to implement it and post a new question once you're stuck. If you combine the two answers (original and update) here, and can't get it to work, you definitely need a clearer question as this is no longer making any sense to me. I will never be able to answer this question. Maybe somebody else can :) . Good luck! – Mrk Sef Mar 23 '22 at 13:41
  • Made another update now, perhaps you can have a look at that and see if it is any clearer. – Mattia Bradascio Mar 23 '22 at 15:27