Monday, July 21, 2025

Notifying Watchers


I think I've reached the point where I've actually done all the Neptune experiments that I really need to do.

Now I'm going to start moving on to building a dummy app that tests that I can do this for real. I'm not going to present all this code because there will be a lot of it and a lot of it is orthogonal to what I really want to do. But it feels that there is one thing that is still relevant to this discussion and that is being aware of connected users.

Before I go any further, I just want to make a few observations that I have noticed in getting to this point.

First off, I'm disappointed that when you choose "serverless" mode for your database, the only way of actually not having a server running is to shut down the database instance. It apparently knows when it is "idle", but still charges you. We're not talking vast sums of money here, but that is different to how (say) Dynamo works.

Secondly, in the introduction I said that I like the way relational databases model relationships but that "they don't scale". It would seem that Neptune is, in fact, built on top of a relational database, not from the ground up. I'm not sure, therefore, what does happen if you try and build a Neptune Cluster across regions or availability zones. I should at least research that if not experiment with that.

Thirdly, my reading of the documentation had led me to believe that I needed two engines: a "writer" and a "reader". That's a fallacy. You need a "primary" (which is both a writer and a reader) and then you can scale by adding more readers. You cannot add multiple writers. So I have shut down one of my two engines.

Tracking Connections

I'm planning on deploying my application to AWS using APIGateway and Lambda. APIGateway (v2) allows websocket connections and, when a websocket connects, provides you with a unique handle that you can later recover to send "unsolicited" messages on (as opposed to replies). I want to store that handle in the neptune graph. Each user can have multiple of these (they could be logged on from a computer and a phone, for instance). From my previous experience, this is just a string, but we will have a node type of Endpoint which could have multiple properties but for now we are just going to model a connectionId.

So I want to do three things:
  • Add a method to "connect" a user by adding a new Endpoint node with a given connectionId in Neptune.
  • Add a mehtod to "disconnect" a user by removing an existing Endpoint with a given connectionId from Neptune.
  • Update the FindWatchers logic to return a list of pairs (username, connectionId).
In the fulness of time, these will just come from the lambda code, but for now I'm going to add a new main() program endpoint which takes three arguments:
  • c to connect or d to disconnect;
  • a userid;
  • a connectionid.
Remember that this code is not expected to generate a unique id; in the case we are considering, that is given to us by APIGateway when the user connects.

Going back to curl, I came up with this to implement connecting:
$ curl https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher -d 'query=
  MATCH (u:User {username:$username})
  CREATE (e:Endpoint {connId:$connId})
  CREATE (u)-[r:endpoint]->(e) RETURN e, r'
-d 'parameters={"username": "user003", "connId": "xx-cc-3"}'
{
  "results": [{
      "e": {
        "~id": "83617f37-c299-4c43-9d56-58e7d97c6b12",
        "~entityType": "node",
        "~labels": ["Endpoint"],
        "~properties": {
          "connId": "xx-cc-3"
        }
      },
      "r": {
        "~id": "5e23d753-f4c2-4ca4-91be-0f366374ad0f",
        "~entityType": "relationship",
        "~start": "492f8845-e1e3-4592-8fb8-27fdeb9351e1",
        "~end": "83617f37-c299-4c43-9d56-58e7d97c6b12",
        "~type": "endpoint",
        "~properties": {}
      }
    }]
}
It took me a while to figure this out. I was trying to understand how I could "create" a node and a relationship at the same time; eventually I realized that this is a "program" and so I can do multiple steps: first, find the user node; then create the new endpoint node with its associated connection id; and then create a relationship between them. I opted to return the answers so that I can see that it worked; it's my expectation that if no user can be found, no new node will be created. I checked and this is correct:
$ curl https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher -d 'query=
  MATCH (u:User {username:$username})
  CREATE (e:Endpoint {connId:$connId})
  CREATE (u)-[r:endpoint]->(e) RETURN e, r'
-d 'parameters={"username": "userNOTFOUND", "connId": "xx-cc-3"}'
{"results":[]}
So in my code I can report if the user matched or not.

Showing Endpoints

Given we have forced an endpoint in there "by hand", I can now make the fairly simple updates to watchers to return the endpoint along with the user:
func FindStockWatchers(db string, stock string) ([]*Connection, error) {
    svc, err := openNeptune(db)
    if err != nil {
        return nil, err
    }
    query := `
    MATCH (u:User)-[r]->(s:Stock {symbol:$symbol})
    MATCH (u)-[]->(e:Endpoint)
    RETURN u.username, s.symbol, e.connId
    `
    params := fmt.Sprintf(`{"symbol": "%s"}`, stock)
    linkQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(query), Parameters: aws.String(params)}
    out, err := svc.ExecuteOpenCypherQuery(context.TODO(), &linkQuery)
    if err != nil {
        return nil, err
    }

    results, err := unpack(out.Results)
    if err != nil {
        return nil, err
    }
    var ret []*Connection
    for _, m := range results {
        ret = append(ret, &Connection{User: m["u.username"].(string), ConnectionId: m["e.connId"].(string)})
    }

    return ret, nil
}

NEPTUNE_SHOW_ENDPOINTS:neptune/internal/neptune/findWatchers.go

I have highlighted the changes. It may be that it is possible to do all of the matching in one MATCH expression, but certainly my openCypher fu is not up to the task. Instead, it's possible to find all the users that have a stock, and then all the endpoints for "that" user. This should return all the endpoints for all users watching the stock (along with the user id). Because we are now returning a pair, we need to declare a struct for that:
package neptune

import "strings"

type Connection struct {
    User         string
    ConnectionId string
}

NEPTUNE_SHOW_ENDPOINTS:neptune/internal/neptune/connection.go

In the main function for watchers, we need to receive this list of connections and display them appropriately:
func main() {
    if len(os.Args) < 2 {
        log.Printf("Usage: watchers <stock>")
        return
    }
    stock := os.Args[1]
    watchers, err := neptune.FindStockWatchers("user-stocks", stock)
    if err != nil {
        panic(err)
    }
    if len(watchers) == 0 {
        fmt.Printf("no watchers found\n")
        return
    }
    slices.SortFunc(watchers, neptune.OrderConnection)
    curr := ""
    fmt.Printf("Stock %s watched by:\n", stock)
    for _, w := range watchers {
        if w.User != curr {
            fmt.Printf("  %s\n", w.User)
            curr = w.User
        }
        fmt.Printf("    connected at %s\n", w.ConnectionId)
    }
}

NEPTUNE_SHOW_ENDPOINTS:neptune/cmd/watchers/main.go

In order to sort the Connections, we need to provide a comparison function, which I've put in the same file as the Connection:
func OrderConnection(left, right *Connection) int {
    ret := strings.Compare(left.User, right.User)
    if ret != 0 {
        return ret
    }
    return strings.Compare(left.ConnectionId, right.ConnectionId)
}

NEPTUNE_SHOW_ENDPOINTS:neptune/internal/neptune/connection.go

And when we run it, we see this output:
Stock UPM6 watched by:
  user003
    connected at xx-cc-3

Deleting Endpoints

When the user disconnects, we will want to delete the associated endpoint. We should be able to find it by matching on connId and then "calling" $DELETE":
$ curl https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher -d 'query=
  MATCH (e:Endpoint {connId:$connId})
  DELETE (e)
  RETURN e'
-d 'parameters={"connId": "xx-cc-3"}'
{
  "code": "BadRequestException",
  "requestId": "4cf7d73a-332c-4cbf-be7c-88e1df36e933",
  "detailedMessage": "Cannot delete node, because it still has relationships. To delete this node, you must first delete its relationships.",
  "message": "Cannot delete node, because it still has relationships. To delete this node, you must first delete its relationships."
}
Unsurprisingly, we are not allowed to delete a node which still has relationships, and this is linked to the user node. However, according to the cheat sheet openCypher has a keyword DETACH to deal with this exact situation.
$ curl https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher -d 'query=
  MATCH (e:Endpoint {connId:$connId})
  DETACH DELETE (e)
  RETURN e'
-d 'parameters={"connId": "xx-cc-3"}'
{
  "results": [{
      "e": {
        "~id": "83617f37-c299-4c43-9d56-58e7d97c6b12",
        "~entityType": "node",
        "~labels": [],
        "~properties": {}
      }
    }]
}
It's interesting to me that this node shows up with the --id that it had when we created it, but its label and properties have been removed before returning it.

Fixing Watchers with no Endpoints

When we run the watchers program again, we see:
no watchers found
Now, I realize at this point that I have significantly changed the semantics of watchers here. When updating FindWatchers above, I was focused on getting the connection back, and that worked for the user with a connection, but now I notice that I am not seeing the watchers on here who are not connected. While that is fine if all I am interested in is notifying connected users, it's not what I intended to happen. (Yes, yes, regression tests, I know.)

The problem, of course, if that I have what in relational terms would be called an "inner" join, but I want a "left" join. Can I do that in openCypher? You betcha. Going back to the cheat sheet, there is a special section on OPTIONAL MATCH which is exactly what we want. If it's there, it's included. If it's not, null comes back in its place. Let's add that.

First, in the query portion:
func FindStockWatchers(db string, stock string) ([]*Connection, error) {
    svc, err := openNeptune(db)
    if err != nil {
        return nil, err
    }
    query := `
    MATCH (u:User)-[r]->(s:Stock {symbol:$symbol})
    OPTIONAL MATCH (u)-[]->(e:Endpoint)
    RETURN u.username, s.symbol, e.connId
    `
    params := fmt.Sprintf(`{"symbol": "%s"}`, stock)
    linkQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(query), Parameters: aws.String(params)}
    out, err := svc.ExecuteOpenCypherQuery(context.TODO(), &linkQuery)
    if err != nil {
        return nil, err
    }

    results, err := unpack(out.Results)
    if err != nil {
        return nil, err
    }
    var ret []*Connection
    for _, m := range results {
        connId := ""
        cid := m["e.connId"]
        if cid != nil {
            connId = cid.(string)
        }
        ret = append(ret, &Connection{User: m["u.username"].(string), ConnectionId: connId})
    }

    return ret, nil
}

NEPTUNE_OPTIONAL_ENDPOINT:neptune/internal/neptune/findWatchers.go

My feeling is that this code is complicated by the way Go handles casting and empty strings, but it is just a question of adding steps and lines rather than explicit complexity. Anyway, the key thing is that when there is no endpoint, the map exists and contains u.username but there is no entry for e.connId, so it is impossible to cast it to string.

Much the same happens in the main routine:
func main() {
    if len(os.Args) < 2 {
        log.Printf("Usage: watchers <stock>")
        return
    }
    stock := os.Args[1]
    watchers, err := neptune.FindStockWatchers("user-stocks", stock)
    if err != nil {
        panic(err)
    }
    if len(watchers) == 0 {
        fmt.Printf("no watchers found\n")
        return
    }
    slices.SortFunc(watchers, neptune.OrderConnection)
    curr := ""
    fmt.Printf("Stock %s watched by:\n", stock)
    for _, w := range watchers {
        if w.User != curr {
            fmt.Printf("  %s\n", w.User)
            curr = w.User
        }
        if w.ConnectionId != "" {
            fmt.Printf("    connected at %s\n", w.ConnectionId)
        }
    }
}

NEPTUNE_OPTIONAL_ENDPOINT:neptune/cmd/watchers/main.go

Here, we may get multiple rows back for any given user, but we will get at least one for each user watching the stock. But we are not guaranteed that there will be a valid ConnectionId; but Go guarantees that in that case there will be an empty string there. So we need to look for that and guard against printing an invalid endpoint.

And, finally, we are back to:
Stock UPM6 watched by:
  user003
  user015
When we have our connection code working, we can check all the other cases also work.

Encoding Connection Updates

We may be further along than it appears, but what we don't have yet is any code to add and delete connections. Let's fix that, starting with the new main() program endpoint:
package main

import (
    "log"
    "os"

    "github.com/gmmapowell/ignorance/neptune/internal/neptune"
)

func main() {
    if len(os.Args) < 3 {
        log.Printf("Usage: endpoint c <user> <connId>")
        log.Printf(" or    endpoint d <connId>")
        return
    }
    command := os.Args[1]
    var err error
    switch command {
    case "c":
        if len(os.Args) != 4 {
            log.Printf("Usage: endpoint c <user> <connId>")
            return
        }
        watcher := os.Args[2]
        connId := os.Args[3]
        err = neptune.ConnectEndpoint("user-stocks", watcher, connId)
    case "d":
        connId := os.Args[2]
        err = neptune.DisconnectEndpoint("user-stocks", connId)
    default:
        log.Printf("Usage: the command must be 'c' or 'd'")
        return
    }
    if err != nil {
        panic(err)
    }
}

NEPTUNE_ENDPOINT_CONNECTOR:neptune/cmd/endpoint/main.go

This is basically just a lot of arguments processing, but it comes down to calling one of two methods ConnectEndpoint and DisconnectEndpoint, which I have put in the same file.

Here is ConnectEndpoint, which should be fairly familiar:
package neptune

import (
    "context"
    "fmt"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/neptunedata"
)

func ConnectEndpoint(db string, watcher string, connId string) error {
    svc, err := openNeptune(db)
    if err != nil {
        return err
    }

    program := `
        MATCH (u:User {username:$username})
        CREATE (e:Endpoint {connId:$connId})
        CREATE (u)-[r:endpoint]->(e)
        RETURN e, r
`
    params := fmt.Sprintf(`{"username": "%s", "connId":"%s"}`, watcher, connId)
    linkQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(program), Parameters: aws.String(params)}
    out, err := svc.ExecuteOpenCypherQuery(context.TODO(), &linkQuery)
    if err != nil {
        return err
    }

    ret, err := unpack(out.Results)
    if err != nil {
        return err
    }

    if len(ret) == 0 {
        return fmt.Errorf("no user found to connect: %s", watcher)
    }

    return nil
}

NEPTUNE_ENDPOINT_CONNECTOR:neptune/internal/neptune/endpoints.go

This is just our usual query infrastructure with the CREATE query we figured out in curl above, and processing to make sure that at least one row of results is returned, otherwise it generates an error that it (presumably) couldn't find the user.

And DisconnectEndpoint:
func DisconnectEndpoint(db string, connId string) error {
    svc, err := openNeptune(db)
    if err != nil {
        return err
    }

    program := `
        MATCH (e:Endpoint {connId:$connId})
        DETACH DELETE (e)
        RETURN e
`
    params := fmt.Sprintf(`{"connId":"%s"}`, connId)
    linkQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(program), Parameters: aws.String(params)}
    out, err := svc.ExecuteOpenCypherQuery(context.TODO(), &linkQuery)
    if err != nil {
        return err
    }

    ret, err := unpack(out.Results)
    if err != nil {
        return err
    }

    if len(ret) == 0 {
        return fmt.Errorf("no connectionId found to disconnect: %s", connId)
    }

    return nil
}

NEPTUNE_ENDPOINT_CONNECTOR:neptune/internal/neptune/endpoints.go

This is the same thing again but with our other (DETACH DELETE) query.

Using this to create an endpoint, we see no feedback because it is written to only provide error feedback:
$ cmd/endpoint/endpoint c user003 xx-cc-3
But we can check that it connected successfully by running our watchers program:
Stock UPM6 watched by:
  user003
    connected at xx-cc-3
  user015
Which also confirms that that handles both the connected and unconnected cases.

And we can likewise run the disconnect version of endpoint and see it return to no endpoints:
$ cmd/endpoint/endpoint d xx-cc-3
$ cmd/watchers/watchers UPM6
Stock UPM6 watched by:
  user003
  user015

Conclusion

We have successfully managed to navigate the processes associated with having endpoint relationships: creating and deleting them, along with finding the endpoints associated with the users watching a stock. This is basically all the (database) code we need in order to implement a stock watching webapp.

Building such a webapp obviously requires a bunch of other code, mainly around wrapping in a lambda and building out a JavaScript client; and then requires more infrastructure in my deployer. I want to do this, but I'm not sure I've got the energy. On the other hand, I do need to implement a lot of that code in my deployer, so it's as good a testbed as anything.

Finding Watchers


Now I want to turn it around the other way and consider what happens when a stock price changes.

On this occasion, I have a fixed stock in mind, and want to find all the users who are watching that stock so that I can notify them that the price has changed.

Going back to curl, we can come up with a suitable query:
curl https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher -d 'query=MATCH (u:User)-[r]->(s:Stock {symbol:$symbol}) RETURN u.username, s.symbol' -d 'parameters={"symbol": "UPM6"}'
{
  "results": [{
      "u.username": "user003",
      "s.symbol": "UPM6"
    }, {
      "u.username": "user015",
      "s.symbol": "UPM6"
    }]
}
If you're surprised that user003 showed up here, don't be. I obtained this stock from the list we generated in the last episode - which was a list of stocks being watched by user003. It would be very strange if it did not show up.

So let's repeat our trick from last time and put that into code, starting with a main:
package main

import (
    "fmt"
    "log"
    "os"
    "slices"

    "github.com/gmmapowell/ignorance/neptune/internal/neptune"
)

func main() {
    if len(os.Args) < 2 {
        log.Printf("Usage: watchers <stock>")
        return
    }
    stock := os.Args[1]
    watchers, err := neptune.FindStockWatchers("user-stocks", stock)
    if err != nil {
        panic(err)
    }
    if len(watchers) == 0 {
        fmt.Printf("no watchers found\n")
        return
    }
    slices.Sort(watchers)
    fmt.Printf("Stock %s watched by:\n", stock)
    for _, w := range watchers {
        fmt.Printf("  %s\n", w)
    }
}

NEPTUNE_FIND_WATCHERS:neptune/cmd/watchers/main.go

This is simpler than before, since we have no need to go to dynamo to find out more information about the stock - since we already started with the stock in hand.

The code to query Neptune is basically just a copy-and-paste of the query to go the other way with the appropriate changes for the query above. I have highlighted the lines that are different.
package neptune

import (
    "context"
    "fmt"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/neptunedata"
)

func FindStockWatchers(db string, stock string) ([]string, error) {
    svc, err := openNeptune(db)
    if err != nil {
        return nil, err
    }
    query := `
    MATCH (u:User)-[r]->(s:Stock {symbol:$symbol})
    RETURN u.username, s.symbol
    `
    params := fmt.Sprintf(`{"symbol": "%s"}`, stock)
    linkQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(query), Parameters: aws.String(params)}
    out, err := svc.ExecuteOpenCypherQuery(context.TODO(), &linkQuery)
    if err != nil {
        return nil, err
    }

    results, err := unpack(out.Results)
    if err != nil {
        return nil, err
    }
    var ret []string
    for _, m := range results {
        ret = append(ret, m["u.username"].(string))
    }

    return ret, nil
}

NEPTUNE_FIND_WATCHERS:neptune/internal/neptune/findWatchers.go

Yes, I agree: if they are so similar, we should probably refactor them to extract the commonalities. On this occasion, I would rather not do that because I'm not exactly sure what the commonalities are and what patterns I would use to put them back together. In short, I'm not sure how much shorter I could make it, and I think the loss of clarity would not be worth it.

And when we're done we end up with:
$ AWS_PROFILE=ziniki-admin cmd/watchers/watchers UPM6
Stock UPM6 watched by:
  user003
  user015

Conclusion

Once you've got things started, it's really easy to run Neptune queries. I'm still struggling with identifying and fixing syntax errors in the openCypher language, mainly because the error messages seem opaque. But as my understanding of the language grows, I'm getting better at it.

Sunday, July 20, 2025

Watching Stocks


I'll often approach software from the top down, but often from the bottom up.

I'm sure there's a lot of interestingly psychology behind this, but I think the simple answer is that it tells you where my focus is. On this occasion my focus is very much on the technology - and particularly on Neptune - and writing an application at all is not very interesting.

But I still think in terms of the application, sketch all of the code from the browser downwards in my head, until I figure out what the eventual method/lambda call would be. So right now I am thinking about a user, who, having logged in (i.e. we have their Username) wants to see the current prices of all the stocks they are watching.

In order to do this, we need to follow all the Watching relationships from the given user, then go back and look up the keys in Dynamo. It is obviously the first of these that is more interesting, but let's do both.

Going back to the cheat sheet, we can look at the code to match a relationship; I think the closest thing they have to what I want is:
MATCH (:Movie {title: 'Wall Street'})<-[:ACTED_IN]-(actor:Person)
RETURN actor.name AS actor
So I want something like this:
MATCH (:User {username: "user008"})-[:watching]->{s:Stock}
RETURN stock.symbol AS symbol
I think I can try this using curl and debug it there:
$ curl -k https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher -d 'query=MATCH (u:User {username:$username})-[r]->(s:Stock) RETURN u.username, s.symbol' -d 'parameters={"username": "user003"}'
{
  "results": [{
      "u.username": "user003",
      "s.symbol": "CWT5"
    }, {
      "u.username": "user003",
      "s.symbol": "AUOR"
    }, {
      "u.username": "user003",
      "s.symbol": "DRYM"
    }, {
      "u.username": "user003",
      "s.symbol": "PMA2"
    }, {
      "u.username": "user003",
      "s.symbol": "UDBO"
    }, {
      "u.username": "user003",
      "s.symbol": "GKXW"
    }, {
      "u.username": "user003",
      "s.symbol": "RTO2"
    }, {
      "u.username": "user003",
      "s.symbol": "IFC0"
    }, {
      "u.username": "user003",
      "s.symbol": "PFW9"
    }, {
      "u.username": "user003",
      "s.symbol": "POCG"
    }, {
      "u.username": "user003",
      "s.symbol": "ZKDI"
    }, {
      "u.username": "user003",
      "s.symbol": "IRI8"
    }, {
      "u.username": "user003",
      "s.symbol": "XEH1"
    }, {
      "u.username": "user003",
      "s.symbol": "UBD9"
    }, {
      "u.username": "user003",
      "s.symbol": "MAGS"
    }, {
      "u.username": "user003",
      "s.symbol": "OGCY"
    }, {
      "u.username": "user003",
      "s.symbol": "WUIU"
    }, {
      "u.username": "user003",
      "s.symbol": "DJBS"
    }, {
      "u.username": "user003",
      "s.symbol": "UPM6"
    }, {
      "u.username": "user003",
      "s.symbol": "ULB4"
    }, {
      "u.username": "user003",
      "s.symbol": "EUT6"
    }, {
      "u.username": "user003",
      "s.symbol": "LNRF"
    }]
}
So now let's try to put that in code. We need a new main program to drive this, which I'll call stockprices:
package main

import (
    "fmt"
    "log"
    "os"
    "slices"

    "github.com/gmmapowell/ignorance/neptune/internal/dynamo"
    "github.com/gmmapowell/ignorance/neptune/internal/neptune"
)

func main() {
    if len(os.Args) < 2 {
        log.Printf("Usage: stockprices <user>")
        return
    }
    user := os.Args[1]
    stocks, err := neptune.FindWatchedStocks("user-stocks", user)
    if err != nil {
        panic(err)
    }
    if len(stocks) == 0 {
        fmt.Printf("no stocks found\n")
        return
    }
    slices.Sort(stocks)
    prices, err := dynamo.FindStockPrices("Stocks", stocks)
    if err != nil {
        panic(err)
    }
    for _, s := range stocks {
        fmt.Printf("%s: %d\n", s, prices[s])
    }
}

NEPTUNE_STOCK_PRICES:neptune/cmd/stockprices/main.go

This calls the neptune engine to find all the stocks associated with the user specified in argument 1, and then goes to dynamo to find the prices of all these stocks. It then sorts the stocks by symbol and prints them out.

Turning to the dynamo code, we have the query from above embedded into a new function FindWatchedStocks:
package neptune

import (
    "context"
    "fmt"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/neptunedata"
)

func FindWatchedStocks(db string, user string) ([]string, error) {
    svc, err := openNeptune(db)
    if err != nil {
        return nil, err
    }
    query := `
    MATCH (u:User {username:$username})-[r]->(s:Stock)
    RETURN u.username, s.symbol
    `
    params := fmt.Sprintf(`{"username": "%s"}`, user)
    linkQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(query), Parameters: aws.String(params)}
    out, err := svc.ExecuteOpenCypherQuery(context.TODO(), &linkQuery)
    if err != nil {
        return nil, err
    }

    results, err := unpack(out.Results)
    if err != nil {
        return nil, err
    }
    var ret []string
    for _, m := range results {
        ret = append(ret, m["s.symbol"].(string))
    }

    return ret, nil
}

NEPTUNE_STOCK_PRICES:neptune/internal/neptune/findStocks.go

I'm not really sure what to say about this, except it is an embedding in Go of the query (with parameters) I showed above. We then go through all the results and extract the fields s.symbol and build a list. The user is the same in all the entries because we specified that in the query parameters.

We then pass this list to the FindPrices function in the dynamo package:
package dynamo

import (
    "context"
    "strconv"

    "github.com/aws/aws-sdk-go-v2/service/dynamodb"
    "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

func FindStockPrices(table string, stocks []string) (map[string]int, error) {
    svc, err := openDynamo()
    if err != nil {
        return nil, err
    }
    var keys []map[string]types.AttributeValue
    var attrs []string
    for _, s := range stocks {
        key := make(map[string]types.AttributeValue)
        key["Symbol"] = &types.AttributeValueMemberS{Value: s}
        keys = append(keys, key)
    }
    attrs = append(attrs, "Symbol", "Price")
    tableRequest := make(map[string]types.KeysAndAttributes)
    tableRequest[table] = types.KeysAndAttributes{Keys: keys, AttributesToGet: attrs}
    out, err := svc.BatchGetItem(context.TODO(), &dynamodb.BatchGetItemInput{RequestItems: tableRequest})
    if err != nil {
        return nil, err
    }

    ret := make(map[string]int)
    for _, x := range out.Responses[table] {
        sym := x["Symbol"].(*types.AttributeValueMemberS).Value
        price := x["Price"].(*types.AttributeValueMemberN).Value
        ret[sym], err = strconv.Atoi(price)
    }
    return ret, nil
}

NEPTUNE_STOCK_PRICES:neptune/internal/dynamo/findPrices.go

This looks complicated, but that's only because the Dynamo API is so awful. For efficiency, we want to fetch all of the stock prices together, which involves a BatchGetItem call. This obviously requires multiple keys, but instead of just taking a list, it insists on having it carefully constructed. I'm sure there's a reason that applies to cases I have never considered.

Separately, it wants a list of attributes that you want to recover. For some reason, it doesn't (as far as I can see) give you the key back, so you have to say that you want the key fields (Symbol) as well as the attribute we want (Price).

And then we receive back a list of Responses (which I think of as Rows, but are more properly Documents). Each of these has an entry for each of the attributes requested, so we need to extract the Symbol and the Price and then we can build a map.

And when we've finished, we get this:
$ cmd/stockprices/stockprices user003
AUOR: 313
CWT5: 189
DJBS: 456
DRYM: 150
EUT6: 181
GKXW: 329
IFC0: 355
IRI8: 478
LNRF: 322
MAGS: 371
OGCY: 434
PFW9: 415
PMA2: 190
POCG: 477
RTO2: 428
UBD9: 424
UDBO: 180
ULB4: 105
UPM6: 239
WUIU: 257
XEH1: 173
ZKDI: 183

Conclusion

We have been able to traverse the relationship graph in Neptune and then use the responses from that to identify the current stock prices. It's easy to imagine how this could be shown in a web app (or on a command line tool).

Saturday, July 19, 2025

Cleaning Neptune


While refactoring, I realized that I didn't have any code to clean a Neptune database, or indeed, any idea about how to go about it. Presumably it is possible to MATCH an arbitrary node and then to say "for each such node, delete it". Let's try that.

In the Cypher Cheat Sheet, there is a section on DELETE and the last example is to delete the entire database. So let's try and get that to work.
func (c *Cleaner) Clean() error {
    clean := `    MATCH (n)
                DETACH DELETE n`
    cleanQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(clean)}
    _, err := c.svc.ExecuteOpenCypherQuery(context.TODO(), &cleanQuery)
    return err
}

NEPTUNE_CLEAN:neptune/internal/neptune/clean.go

OK, that was easy. Can I really stop there and call this a whole episode?

Conclusions

Once you've done all the previous steps, finding the right query and just executing it seems to be fairly simple. Always assuming, of course, that we did succeed in deleting everything in the database.

Friday, July 18, 2025

Node Creation Syntax


So, if it's not obvious, there is something wrong with our "query" (which is not a query in the normal sense but something we are trying to do).

So let's try something simpler. Last time, I managed to get a very simple "query" to work from the command line:
$ curl https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher?query="RETURN%201"
{
  "results": [{
      "1": 1
    }]
}
So let's see if we can get that working from within the Go program:
func (nc *NodeCreator) Insert(label string) error {
    r1 := "RETURN 1"
    insertQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(r1)}
    out, err := nc.svc.ExecuteOpenCypherQuery(context.TODO(), &insertQuery)
    if err != nil {
        return err
    }
    var results []map[string]any = nil
    err = out.Results.UnmarshalSmithyDocument(&results)
    if err != nil {
        return err
    }
    for _, m := range results {
        for k, v := range m {
            log.Printf("result %s => %s\n", k, v)
        }
    }
    return err
}

NEPTUNE_SIMPLER_QUERY:neptune/cmd/create/main.go

The "query" portion of this is really quite simple: we simply pass RETURN 1 as OpenCypherQuery. Processing the response is quite hard, though. The structure is the same as we saw on the command line, so the output value has a Results member. This can only be accessed however, through an inversion-of-control method UnmarshalSmithyDocument (this is described here).

In order to get that to work, you need a variable of "the right type". I figured this out by reading the error messages one at a time and fixing one issue at a time. In retrospect, it's possible to look at the output above and see that we have a list of maps. I don't understand enough about how openCypher works yet to understand what all the possibilities are, but suffice it to say that the 1 I wanted to RETURN is the value of the key "1" in the map.

When I run this program, I get this:
2025/07/12 12:06:53 result 1 => 1
Excellent.

Creating Nodes

Given that all the examples in the documentation use curl, let's try to do that and then try and back into what we really want. So here's an example from the Neptune manual:
$ curl https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher -d "query=CREATE (n: Person { age: 25})"
{"results":[]}
So this "works", so let's try that from Go:
func (nc *NodeCreator) Insert(label string) error {
    r1 := "CREATE (n: Person { age: 25})"
    insertQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(r1)}
    out, err := nc.svc.ExecuteOpenCypherQuery(context.TODO(), &insertQuery)
    if err != nil {
        return err
    }

NEPTUNE_SIMPLE_CREATE:neptune/cmd/create/main.go

And, as you would expect, there is no output, since there are no return values (and no error).

Parameters

We want to create nodes with parameters, so let's try that.

Parameterized queries are described in a separate section of the manual, and there are no examples using create. But we can adapt one of the match queries to our endpoint and try this:
$ curl -k https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher -d "query=MATCH (n {name: \name, age: \age}) RETURN n" -d "parameters={\"name\": \"john\", \"age\": 20}"
{"results":[]}
Now, having come this far, I'm disappointed not to see any results. So, given that we created a node above, before we convert this to Go, I'm going to try actually obtaining a match.
$ curl -k https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher -d "query=MATCH (n {age: \$age}) RETURN n" -d "parameters={\"age\": 25}"
{
  "results": [{
      "n": {
        "~id": "dfe31b9c-0925-4744-bfc8-15b233cf4f48",
        "~entityType": "node",
        "~labels": ["Person"],
        "~properties": {
          "age": 25
        }
      }
    }, {
      "n": {
        "~id": "f7fe6f4c-22cd-4d3e-9bbc-9dd755d5918d",
        "~entityType": "node",
        "~labels": ["Person"],
        "~properties": {
          "age": 25
        }
      }
    }, {
      "n": {
        "~id": "7510e8e0-36be-42e1-a18c-584d1e3e5b34",
        "~entityType": "node",
        "~labels": ["Person"],
        "~properties": {
          "age": 25
        }
      }
    }]
}
I have to admit, I'm somewhat surprised to have three results here, but that is presumably because I ran three different commands to insert (the same) record.

Right, let's do that in Go. The tricky thing is figuring out which of the quotes and backslashes above are just for the shell and which are needed, but after a couple of tries I came up with this, which seems to work:
func (nc *NodeCreator) Insert(label string) error {
    r1 := "MATCH (n {age: $age}) RETURN n"
    params := `{"age": 25}`
    insertQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(r1), Parameters: aws.String(params)}
    out, err := nc.svc.ExecuteOpenCypherQuery(context.TODO(), &insertQuery)
    if err != nil {
        return err
    }

NEPTUNE_PARAM_MATCH:neptune/cmd/create/main.go

2025/07/12 12:45:11 result n => map[~entityType:node ~id:dfe31b9c-0925-4744-bfc8-15b233cf4f48 ~labels:[Person] ~properties:map[age:25]]
2025/07/12 12:45:11 result n => map[~entityType:node ~id:f7fe6f4c-22cd-4d3e-9bbc-9dd755d5918d ~labels:[Person] ~properties:map[age:25]]
2025/07/12 12:45:11 result n => map[~entityType:node ~id:7510e8e0-36be-42e1-a18c-584d1e3e5b34 ~labels:[Person] ~properties:map[age:25]]

Finally Back to Creation

So, with all that in the bag, let's go back and try and do a parameterized creation in Neptune. Again, it took a couple of goes to get where I wanted to be, but this does work:
func (nc *NodeCreator) Insert(label string) error {
    create := "CREATE (n:stock {symbol: $symbol})"
    params := fmt.Sprintf(`{"symbol": "%s"}`, label)
    insertQuery := neptunedata.ExecuteOpenCypherQueryInput{OpenCypherQuery: aws.String(create), Parameters: aws.String(params)}
    out, err := nc.svc.ExecuteOpenCypherQuery(context.TODO(), &insertQuery)
    if err != nil {
        return err
    }

NEPTUNE_CREATE_STOCK:neptune/cmd/create/main.go

And I can run a query from the command line that checks that the entry was inserted.

Conclusions

This was a lot less painful than all the time I spent trying to figure out the connectivity issues. And now we have managed to create a single stock in both Dynamo and Neptune. I want to do a bit of refactoring, but then next time we will be in a position to create 2000 stocks and 100 users and couple them up.

Thursday, July 17, 2025

Connecting to Neptune


Before we can go any further, we need to sort out the connectivity issues.

Looking at the console, I can see that I have two endpoints, one for reading and one for writing. For our current tasks, we obviously need the one for writing. I can copy this and hardcode it into my application (always remembering to add https:// on the front and $:8182/" on the end.
    nodeCreator, err := NewNodeCreator("https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/")
    if err != nil {
        log.Fatal(err)
    }

    err = nodeCreator.Insert("HWX2")
    if err != nil {
        log.Fatal(err)
    }

NEPTUNE_ENDPOINT:neptune/cmd/create/main.go

and then update the code to connect using one of the "modify options" functions that NewFromConfig supports:
func NewNodeCreator(endpoint string) (*NodeCreator, error) {
    svc, err := openNeptune(endpoint)
    if err != nil {
        return nil, err
    } else {
        return &NodeCreator{svc: svc}, nil
    }
}

func openNeptune(endpoint string) (*neptunedata.Client, error) {
    cfg, err := config.LoadDefaultConfig(context.TODO())
    if err != nil {
        return nil, err
    }
    cli := neptunedata.NewFromConfig(cfg, func(opts *neptunedata.Options) {
        opts.BaseEndpoint = aws.String(endpoint)
        log.Printf("%s\n", *opts.BaseEndpoint)
    })
    return cli, nil
}

NEPTUNE_ENDPOINT:neptune/cmd/create/main.go

Now I get this error:
2025/07/11 08:07:22 https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182
2025/07/11 08:07:22 operation error neptunedata: ExecuteOpenCypherQuery, https response error StatusCode: 0, RequestID: , request send failed, Post "https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/opencypher": dial tcp: lookup user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com: no such host
This is actually progress. This is the URL I want to connect to, but I don't have access to it (and the reason I'm willing to print it here is because you don't either). This is an address within a VPC.

In "the real world", I will want to package all of my code into a lambda, deploy the lambda into the VPC and connect that to an APIGateway (yes, I hope to get there if I don't get ground down). But for now, I want to connect from my development machine. How do I do that?

The sample code I found has the following to say on the matter:
  ------------------------------------------------------------------------------
  VPC NETWORKING REQUIREMENT:
  ------------------------------------------------------------------------------
  Amazon Neptune must be accessed from *within the same VPC* as the Neptune cluster.
  It does not expose a public endpoint, so this code must be executed from:
 
   - An AWS Lambda function configured to run inside the same VPC
   - An EC2 instance or *ECS task* running in the same VPC
   - A connected environment such as a VPN, AWS Direct Connect, or a peered VPC
So I'm going to try and connect a VPN to AWS from my machine.

Connecting a VPN

The documentation on this seems to start here. As somebody posing as an administrator, I also need to set up an endpoint using the administrator guide. Note that this service is NOT free (indeed, charges you on an hourly basis just for having it set up), so be aware of the VPN pricing before you begin, if you are going to do this.

Since I am just following AWS instructions for this, I'm not going to go into too much detail, but for my own notes as much as anything:
  • Note down a CIDR block associated with your VPC and Neptune subnet
  • Go to the Client VPN endpoints tab of the VPC screen.
  • Click on the "Create client VPN" button.
  • Enter the information they want, including the CIDR block noted earlier.
I wasn't really sure what to do with the Authentication Information section, so, since I have certificates in ACM, I decided to go with mutual authentication and the same certificate on both sides. We'll see what happens.

Among the optional parameters, I chose to specify the VPC, although I figure this would be obvious (the fact that it offers me a choice suggests it is less obvious than I realise).
  • Click on "Create client VPN endpoint".
I had a couple of issues. Firstly, the CIDR range I had selected was invalid. My subnets are "24-bit" (/24), but apparently the smallest range you can have is 22, so I had to widen it. Then it turned out the certificate I had selected was "not in the Issued state" (don't ask), and so I had to go and choose another one.

This seems like the end of the process, but it is not. The endpoint shows up in the list, but it is in the "pending-associate" state. Who knows what this means?

Scouring the documentation, it seems to be the case that we need to associate the endpoint with a target network, so we click on the "Target network associations" tab and click "Associate target network".

This then requires us to:
  • Choose a VPC
  • Choose a subnet
(I thought I did this already in optional properties, but there you go).

Ah, and then we run into problems. This subnet overlaps with the specified CIDR we used above, so apparently the CIDR they want you to specify is NOT the CIDR of your existing subnet, but a different one.

OK, let's go back, delete this one and start again (this is turning into a long process; sorry about that, but one of the purposes I have here is to record the problems I ran into, mainly because nobody else does).

And when I have gone through all that, I can successfully associate the target network, but it still remains in the "pending-associate" state. However, I can see in the lower tab on "Target network associations" that there is an association and it is in the "Associating" state. I presume this is just a question of waiting.

So, now, turning to the client portion of the process, you need to click on Download the AWS Client VPN from the self-service portal from the user-getting-started page. We need to copy across the cvpn ID. When I enter this into the form, it tells me that I need to contact my IT administrator. Turning to myself, I ask, "can you help me with this?" to which I answer, "I think we probably need to wait for the association to complete". OK, then, time for a cup of tea.

I didn't time it exactly, but to give an idea, it was about ten minutes from associating to reaching the "Associated" state. Let's try the client again.

No, it still doesn't work. Digging around some more, I found this article, which has a fair amount more detail than I'd seen before. At the bottom, it lists the additional steps I need to carry out. I'd already figured out the "pending-associate" one, but carrying on:
  • I need to add an authorization rule to a "destination" network. I would presume this is the CIDR of the VPN, but it might be the subnet. Anyway, 0.0.0.0/0 is an option, so I'm going for that. The endpoint now enters the "Authorizing" state, so I will have to wait for a bit, although it turns out to be less time than it's taken me to type this.
  • I need to export the client configuration file by going to the main list and clicking on "Download Client Configuration". The name and alleged action don't seem to match, but it does indeed download something.
  • Because I opted to use mutual certificate authentication, I now need to insert the information about my certificate into the downloaded file. Interestingly, it seems to have already completed the <ca> section, but not the <cert> or <key> aspects. I can do that.
Having done that, I imported that into my OpenVPN client on my Mac and attempted to connect. Sadly, it reported that it could not find the endpoint. Is this just DNS being slow to propagate, or is this something not right with my configuration, or is it an issue with the client? To eliminate one variable, I decided to use the official AWS client. Sadly, that didn't work either, but it said that the "connection failed because of a TLS handshake error". Now, that would not surprise me if it was to do with the certificate and CA issues.

Having gone around and around in circles for a while, I decided to spread my net further and came across this guide to creating what appear to be self-signed client and server certificates and importing them for this purpose.

There is a lot of detritus in the output here which I have excerpted for length.
$ mkdir neptune-vpn
$ cd neptune-vpn
$ git clone https://github.com/OpenVPN/easy-rsa.git
Cloning into 'easy-rsa'...
...
$ easy-rsa/easyrsa3/easyrsa init-pki
...
'init-pki' complete; you may now create a CA or requests.
...
$ easy-rsa/easyrsa3/easyrsa build-ca nopass
...
Common Name (eg: your user, host, or server name) [Easy-RSA CA]:gmmapowell.com
$ easy-rsa/easyrsa3/easyrsa --san=DNS:neptune.gmmapowell.com build-server-full server nopass
Generating a 2048 bit RSA private key
...
$ easy-rsa/easyrsa3/easyrsa build-client-full gareth.gmmapowell.com nopass
Generating a 2048 bit RSA private key
...
$ mkdir upload
$ cp pki/ca.crt upload/
$ cp pki/issued/*.crt upload
$ cp pki/private/*.key upload
$ cd upload/
$ AWS_PROFILE=ziniki-admin aws acm import-certificate --certificate fileb://server.crt --private-key fileb://server.key --certificate-chain fileb://ca.crt
{
    "CertificateArn": "arn:aws:acm:us-east-1:331358773365:certificate/3d12f227-54ec-4ffb-be6f-a02750b4b975"
}
$ AWS_PROFILE=ziniki-admin aws acm import-certificate --certificate fileb://gareth.gmmapowell.com.crt --private-key fileb://gareth.gmmapowell.com.key --certificate-chain fileb://ca.crt
{
    "CertificateArn": "arn:aws:acm:us-east-1:331358773365:certificate/bbdb1930-a190-4f83-b6e2-be4f70a73ca8"
}
Having done all this, it's now necessary to delete all of the VPN endpoint configuration files; this requires us to disassociate the network association and delete the endpoint. We can then start again.

And then, yes! I'm connected. Still, two hours of my life I'll never get back.

Limiting the Connection

As soon as I do this, I find I can't connect to the rest of the internet. Why not? Well, all of my internet traffic is being sent down the VPN.

I've been here before, but struggled. This answer says to use the argument route-nopull in the configuration file, but when I tried that, the Amazon client objected, saying it wasn't supported. Using this file with my (normal) OpenVPN command works providing I make a change to the remote name as follows:
remote cvpn-endpoint-02fe15ffc9f92baae.prod.clientvpn.us-east-1.amazonaws.com 443
remote-random-hostname
becomes
remote foo.cvpn-endpoint-02fe15ffc9f92baae.prod.clientvpn.us-east-1.amazonaws.com 443
# remote-random-hostname
The thing here is that the Amazon client can handle the task of adding "random" parts to the front of the remote name in order to bust the DNS cache. The standard client does not have that, so you need to specify a specific remote host, but any name will do.

Attempting to Connect over the VPN

Running the program again leads to the problem that we still cannot resolve the hostname. Looking at this article, step 2 says that you should be able to set the DNS resolver as being the ".2" IP address in the VPC. This still does not work for me (I am trying with both the OpenVPN client and the Amazon Client).

Can I connect directly via the IP address? Sadly, I cannot see an ip address anywhere, but I can look it up from the DNS server if, indeed, it is where the documentation says to look. Let's try that (this obviously requires the VPN to be connected):
$ nslookup user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com 172.28.0.2
Server: 172.28.0.2
Address: 172.28.0.2#53
* server can't find user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com: NXDOMAIN
Hands up anyone who is surprised by that.

As a side benefit, this has enabled me to check my OpenVPN connection is working properly, because I can see a clear difference here between being connected to the VPN (I get NXDOMAIN) and not being connected (connection times out). So, given that I receive the same NXDOMAIN message using the standard OpenVPN client as I do using the AWS client, I clearly have connectivity to the VPC through that. Very good.

So I'm left with one of two options: the DNS is not working, or I simply don't have an instance to connect to. The latter seems more likely. I can't see anywhere to add an instance to my current cluster, but interestingly there is an option to add an extra "reader". When I try this, it tells me that I cannot add a reader without a writer. So, no, I don't think I have an instance.

OK, this is frustrating, but this is why we are here: to have these problems in a nice little sandbox and to figure them out. Let me go back to my deployment script and add two DBInstances: one for writing and one for reading.
So now we can create the Cluster.  It would seem that you can create a cluster with little more
than a name and a SubnetGroup.

        ensure aws.Neptune.Cluster "user-stocks" => cluster
            @teardown delete
            SubnetGroupName <- subnet
            MinCapacity <- 1.0
            MaxCapacity <- 1.0

We need to create Neptune Writer and Reader instances

        ensure aws.Neptune.Instance "writer"
            @teardown delete
            Cluster <- cluster
            InstanceClass <- "serverless"

        ensure aws.Neptune.Instance "reader"
            @teardown delete
            Cluster <- cluster
            InstanceClass <- "serverless"

NEPTUNE_INSTANCES:neptune/dply/infrastructure.dply

Each instance needs an InstanceClass - the type of machine being provisioned to do the work. It turns out that one of the options is to specify the db.serverless class, which automatically scales relative to the work. It does however require parameters to be set to specify the minimum and maximum capacity of the system, and those need to be set on the cluster. So we can go back and add those parameters to the cluster and have it do all the relevant work for us.

Wow, that took a long time to create. 20 minutes for the writer and 35 for the reader (Note to self: should I allow these to be created in parallel in my deployer, or is that just 15 for the reader but it waits until the writer is available?).

And now, when I try to access the DNS, I see this:
nslookup user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com 172.28.0.2
Server: 172.28.0.2
Address: 172.28.0.2#53
Non-authoritative answer:
user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com canonical name = writer.ckgvna81hufy.us-east-1.neptune.amazonaws.com.
Name: writer.ckgvna81hufy.us-east-1.neptune.amazonaws.com
Address: 172.28.130.87
Great! Let's try the app again...

No, no improvement. But that's not surprising. The app still can't resolve the DNS name. Let's try the IP address now we know it.
2025/07/11 17:02:15 https://172.28.130.87:8182/
2025/07/11 17:02:17 operation error neptunedata: ExecuteOpenCypherQuery, exceeded maximum number of attempts, 3, https response error StatusCode: 0, RequestID: , request send failed, Post "https://172.28.130.87:8182/opencypher": tls: failed to verify certificate: x509: “*.ckgvna81hufy.us-east-1.neptune.amazonaws.com” certificate is not standards compliant
Well, well. Not working, but a different error which makes it quite clear that we have connectivity. I'm not convinced I like this error, but I'm going to assume that the problem is just that I am using an IP address. So how do we resolve this (no pun intended)?

The answer, of course, is to have a custom resolver that knows how to figure out what the IP address is, even though it isn't in the default DNS service. There appear to be such things in the Go library, but in my frazzled state I can't figure that out. Much simpler in the short term is just to add an entry to /etc/hosts:
172.28.130.87 user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com
(In the long term, we will be deploying everything inside the VPC and it should "just work".)

Sadly, though, the error remains the same:
2025/07/11 17:34:05 https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/
2025/07/11 17:34:08 operation error neptunedata: ExecuteOpenCypherQuery, exceeded maximum number of attempts, 3, https response error StatusCode: 0, RequestID: , request send failed, Post "https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/opencypher": tls: failed to verify certificate: x509: “*.ckgvna81hufy.us-east-1.neptune.amazonaws.com” certificate is not standards compliant
Back to Google:

This Github issue seems to suggest that the root issue is that Apple changed something at some point in the way they handle certain kinds of certificates, so it's probably worth trying this on Linux, so I'll do that in a bit. Certainly it seems to be an issue that has intersections between Apple, Go and AWS.

There was a suggestion that it might help to add the various AWS root certificates to the x509.SystemCertPool(). I tried this, but it didn't help me - maybe I did it wrong.

Thinking about it, I can eliminate the Go library from the equation by using curl (I tried this before, but since then I've resolved the DNS problem):
curl https://user-stocks.cluster-ckgvna81hufy.us-east-1.neptune.amazonaws.com:8182/openCypher?query="RETURN%201"
{
  "results": [{
      "1": 1
    }]
}
Interesting. So that just works. I was thinking I might have to specify the -k flag (to ignore certificate issues).

On Linux

OK, let's now try all this on Linux instead of MacOS.

In getting in to run on Linux, I ran into a whole bunch of other random problems, like needing to update my dlv debugger (for which this eventually worked for me: go install github.com/go-delve/delve/cmd/dlv@latest).

But then once I had brought everything up to speed, including running the Linux openvpn client with the same configuration file (as root, of course):
$ sudo openvpn --config ~/Ziniki/Credentials/neptune-vpn/neptune-cert-nopull.ovpn
and updating my /etc/hosts file (also as root), I got this:
2025/07/11 20:23:01 operation error neptunedata: ExecuteOpenCypherQuery, https response error StatusCode: 400, RequestID: 1552f1a1-e1fa-4623-aded-d4a8aeda9d47, MalformedQueryException:
OK, it's not "working" but it's not a stupid certificate exception. At least that feels like progress. The setup I have in my flat makes it easier to develop on my Mac when I'm there, but I can slum it on this laptop if it means the code will work.

Removing the Hardcoded Hostname

OK, at a totally different level of complexity, can I remove the need to hardcode the endpoint in the code? Theoretically, at least, I can use the Neptune API to obtain the endpoints associated with a cluster. Let's try this.

The bulk of the changes are in the openNeptune() function which no longer takes an endpoint:
func openNeptune() (*neptunedata.Client, error) {
    cfg, err := config.LoadDefaultConfig(context.TODO())
    if err != nil {
        return nil, err
    }
    nc := neptune.NewFromConfig(cfg)
    endpoints, err := nc.DescribeDBClusterEndpoints(context.TODO(), &neptune.DescribeDBClusterEndpointsInput{DBClusterIdentifier: aws.String("user-stocks")})
    if err != nil {
        return nil, err
    }
    if len(endpoints.DBClusterEndpoints) < 1 {
        return nil, fmt.Errorf("no cluster endpoints found")
    }
    endpoint := *endpoints.DBClusterEndpoints[0].Endpoint
    cli := neptunedata.NewFromConfig(cfg, func(opts *neptunedata.Options) {
        opts.BaseEndpoint = aws.String("https://" + endpoint + ":8182/")
    })
    return cli, nil
}

NEPTUNE_FIND_ENDPOINT:neptune/cmd/create/main.go

And that all works. Great.

(As noted above, this doesn't solve the problem with needing to hardcode it in /etc/hosts, but that is not a long term problem in the way that looking up the Neptune host is once we deploy into a lambda.)

Conclusion

This has been a truly frustrating day. I view myself as a software engineer, not someone who troubleshoots network and communication issues. And yet, I feel like I have spent all day doing one or another task that is just trying to figure out why some piece of infrastructure is not working the way I would expect it to. Hopefully I have reached the end of that now, and the last problem here is one in the code where I am not assembling a query correctly. We will see another day.