From 3a02784ecb15266ac6aadb19a16ddf8dc294b538 Mon Sep 17 00:00:00 2001 From: Patrick Deziel Date: Fri, 27 Jan 2023 13:52:02 -0600 Subject: [PATCH 1/3] ensign for developers --- docs/content/examples/developers.md | 405 +++++++++++++++++++++++++ docs/content/getting-started/_index.md | 12 +- 2 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 docs/content/examples/developers.md diff --git a/docs/content/examples/developers.md b/docs/content/examples/developers.md new file mode 100644 index 000000000..6f95bbf96 --- /dev/null +++ b/docs/content/examples/developers.md @@ -0,0 +1,405 @@ +--- +title: "Ensign for Application Developers" +weight: 30 +bookFlatSection: false +bookToc: true +bookHidden: false +bookCollapseSection: false +bookSearchExclude: false +--- + +# Ensign for Application Developers + +Hi there! This tutorial is targeted towards Golang application developers. If you are interested in or currently writing event-driven applications in Go you are in the right place! In this code-driven tutorial we will use the Ensign Golang SDK to publish curated tweets to an event stream and retrieve them in real time. + +If you came here for the code the full example is available [here](https://github.com/rotationalio/ensign-examples/tree/main/go/tweets). + +## Prerequisites + +To follow along with this tutorial you'll need to: + +- [Generate an API key to access Ensign]({{< ref "/getting-started#getting-started" >}}) +- [Set up a developer account with Twitter (it's free)](https://developer.twitter.com/en/docs/twitter-api/getting-started/getting-access-to-the-twitter-api) +- [Add a phone number to your Twitter developer account](https://help.twitter.com/en/managing-your-account/how-to-add-a-phone-number-to-your-account) +- [Set up your GOPATH and workspace](https://go.dev/doc/gopath_code) + +## Project Setup + +The first thing we need to do is setup an environment to run our code. Let's create a blank module with a suitable name for our project: + +```bash +$ mkdir tweets +$ go mod init github.com/rotationalio/ensign-examples/go/tweets +``` + +Next we'll need to install the Go SDK client and its depenencies from the GitHub [repo](https://github.com/rotationalio/ensign). In this tutorial we also use the [go-twitter](https://github.com/g8rswimmer/go-twitter) client to interact with the twitter API (although you can also create the requests yourself)! + +```bash +$ go get -u github.com/rotationalio/ensign/sdks/go@latest +$ go get -u github.com/g8rswimmer/go-twitter/v2@latest +``` + +Our project needs a [publisher]({{< ref "/eventing#publisher" >}}) to write events to Ensign and a [subscriber]({{< ref "/eventing#subscriber" >}}) to read those events (asynchronously). In a real application these would most likely be independent microservices that run in different execution contexts (e.g. containers in a k8s cluster or even across different regions). Let's create separate packages for the two command line applications as well as a shared package for our event schemas. + +```bash +$ mkdir publish +$ mkdir subscribe +$ mkdir schemas +``` + +## Sourcing Tweets + +In event-driven systems, events are the main unit of data. In production applications events might be sourced from user actions, IoT devices, webhooks, or act as control signals between microservices. + +For this example our data source is curated tweets from twitter. Create a file called `main.go` in the `publish` directory and add the following code to it. + +```golang +package main + +import ( + "flag" + "fmt" + "net/http" + "time" + + "context" + + twitter "github.com/g8rswimmer/go-twitter/v2" +) + +type authorize struct { + Token string +} + +func (a authorize) Add(req *http.Request) { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.Token)) +} + +func main() { + var ( + err error + token string + ) + + if token = os.Getenv("TWITTER_API_BEARER_TOKEN"); token == "" { + panic("TWITTER_API_BEARER_TOKEN environment variable is required") + } + + query := flag.String("query", "distributed systems", "Twitter search query") + flag.Parse() + + tweets := &twitter.Client{ + Authorizer: authorize{ + Token: *token, + }, + Client: http.DefaultClient, + Host: "https://api.twitter.com", + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var rep *tweets.TweetRecentSearchResponse + if rep, err = client.TweetRecentSearch(ctx, *query, twitter.TweetRecentSearchOpts{}); err != nil { + panic(err) + } + + for _, errs := range rep.Raw.Errors { + fmt.Printf("Error: %s\n", errs.Detail) + } + + for _, tweet := range rep.Raw.Tweets { + fmt.Printf("%s: %s\n", tweet.AuthorID, tweet.Text) + } +} +``` + +This is a simple command line application that will retrieve a single page of search results from twitter and print them out. Feel free to build the program and run it with any search query to make sure it works! + +```bash +$ export TWITTER_API_BEARER_TOKEN=# Your Twitter API bearer token goes here +$ cd publish +$ go build -o publish main.go +$ ./publish --query "distributed systems" +``` + +## Creating a Publisher + +Now that we have a data source, the next step is to create an Ensign client using the Client ID and Client Secret pair you received when [generating your API key]({{< ref "/getting-started#getting-started" >}}). + +```golang +import ( +... + twitter "github.com/g8rswimmer/go-twitter/v2" + ensign "github.com/rotationalio/ensign/sdks/go" +) +``` + +```golang +func main() { + var ( + err error + token string + ) + + if token = os.Getenv("TWITTER_API_BEARER_TOKEN"); token == "" { + panic("TWITTER_API_BEARER_TOKEN environment variable is required") + } + + query := flag.String("query", "distributed systems", "Twitter search query") + flag.Parse() + + // ENSIGN_CLIENT_ID and ENSIGN_CLIENT_SECRET environment variables must be set + var client *ensign.Client + if client, err = ensign.New(&ensign.Options{}); err != nil { + panic("failed to create Ensign client: " + err.Error()) + } +... +``` + +In the Go SDK, creating a `Publisher` interface from the client is straightforward. + +```golang + var pub ensign.Publisher + if pub, err = client.Publish(context.Background()); err != nil { + panic("failed to create publisher from Ensign client: " + err.Error()) + } +``` + +## Publishing Events + +In Ensign, events include a lot more than the data itself. As we can see from the [protocol buffer](https://github.com/rotationalio/ensign/blob/main/pkg/api/v1beta1/event.pb.go), events are self-descriptive and are quite flexible. + +```golang +type Event struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + TopicId string `protobuf:"bytes,2,opt,name=topic_id,json=topicId,proto3" json:"topic_id,omitempty"` + Mimetype v1beta1.MIME `protobuf:"varint,3,opt,name=mimetype,proto3,enum=mimetype.v1beta1.MIME" json:"mimetype,omitempty"` + Type *Type `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"` + Key []byte `protobuf:"bytes,5,opt,name=key,proto3" json:"key,omitempty"` + Data []byte `protobuf:"bytes,6,opt,name=data,proto3" json:"data,omitempty"` + Encryption *Encryption `protobuf:"bytes,7,opt,name=encryption,proto3" json:"encryption,omitempty"` + Compression *Compression `protobuf:"bytes,8,opt,name=compression,proto3" json:"compression,omitempty"` + Geography *Region `protobuf:"bytes,9,opt,name=geography,proto3" json:"geography,omitempty"` + Publisher *Publisher `protobuf:"bytes,10,opt,name=publisher,proto3" json:"publisher,omitempty"` + UserDefinedId string `protobuf:"bytes,11,opt,name=user_defined_id,json=userDefinedId,proto3" json:"user_defined_id,omitempty"` + Created *timestamppb.Timestamp `protobuf:"bytes,14,opt,name=created,proto3" json:"created,omitempty"` + Committed *timestamppb.Timestamp `protobuf:"bytes,15,opt,name=committed,proto3" json:"committed,omitempty"` +} +``` + +For this tutorial we are mostly concerned with the following fields. + +- `TopicId`: Events are organized into [topics](({{< ref "/eventing#topic" >}})) and events in a topic usually follow a similar schema +- `Mimetype`: In Ensign all event data is generic "blob" data to allow for heterogenous event streams. The mimetype allows subcribers to deserialize data back into an understandable format. +- `Type`: Events in Ensign are tagged with schema type and versioning info to allow publishers and subscribers to lookup schemas in a shared registry. This is important because certain serialization methods (e.g. protobuf, parquet) require explicit schemas for deserialization and schema-less methods (e.g. JSON) can be enhanced with versioning. + +In this example we can get away with structured JSON. In production worfklows we would most likely want to store the definition in a schema registry but for now let's add it to `tweets.go` in the `schemas` directory so both our producer and subscriber can access it. + +```golang +package schemas + +type Tweet struct { + Author string `json:"author"` + Text string `json:"text"` + CreatedAt string `json:"created_at"` +} +``` + +Now that we know how to serialize JSON, in the tweet loop instead of printing to the console let's go ahead and publish some events. + +```golang + for _, tweet := range rep.Raw.Tweets { + e := &api.Event{ + TopicId: "tweets", + Mimetype: mimetype.ApplicationJSON, + Type: &api.Type{ + Name: "tweet", + Version: 1, + }, + } + + tweetObj := &schemas.Tweet{ + Author: tweet.AuthorID, + Text: tweet.Text, + CreatedAt: tweet.CreatedAt, + } + if e.Data, err = json.Marshal(tweetObj); err != nil { + panic("could not marshal tweet to JSON: " + err.Error()) + } + + // Publish the event to Ensign + pub.Publish(e) + + // Check for errors + if err = pub.Err(); err != nil { + panic("failed to publish event(s): " + err.Error()) + } + } +``` + +If your IDE did not resolve the imports for you, you will need to specify them manually: + +```golang +import ( +... + api "github.com/rotationalio/ensign/pkg/api/v1beta1" + mimetype "github.com/rotationalio/ensign/pkg/mimetype/v1beta1" +... +) +``` + +*Note that `pub.Publish(e)` does not return an immediate error, it's an asynchronous operation so if we want to check for errors we have to do so after the fact. This means that we can't be sure which event actually triggered the error.* + +Finally, to make our publisher feel like a real service, we can add an outer loop with a ticker so that the program periodically pulls the most recent tweets our search query of choice. Another useful improvement might be to utilize the `SinceID` on the twitter search options so that we aren't producing duplicate tweets! + +```golang + ticker := time.NewTicker(10 * time.Second) + sinceID := "" + for { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + select { + case <-ctx.Done(): + return + case <-ticker.C: + fmt.Println("searching for tweets...") + opts := twitter.TweetRecentSearchOpts{ + SortOrder: twitter.TweetSearchSortOrderRecency, + SinceID: sinceID, + } + + var rep *twitter.TweetRecentSearchResponse + if rep, err = tweets.TweetRecentSearch(ctx, *query, opts); err != nil { + panic(err) + } + + for _, errs := range rep.Raw.Errors { + fmt.Printf("Error: %s\n", errs.Detail) + } + + for _, tweet := range rep.Raw.Tweets { + e := &api.Event{ + TopicId: "tweets", + Mimetype: mimetype.ApplicationJSON, + Type: &api.Type{ + Name: "Generic", + Version: 1, + }, + } + + if e.Data, err = json.Marshal(tweet); err != nil { + panic("could not marshal tweet to JSON: " + err.Error()) + } + + pub.Publish(e) + + if err = pub.Err(); err != nil { + panic("failed to publish event(s): " + err.Error()) + } + + fmt.Printf("published tweet with ID: %s\n", tweet.ID) + } + + if len(rep.Raw.Tweets) > 0 { + sinceID = rep.Raw.Tweets[0].ID + } + } + } +``` + +At this point our publisher will be able to request some new tweets from Twitter every 10 seconds and publish them as events to the `tweets` topic. Go ahead and try it out! + +```bash +$ export ENSIGN_CLIENT_ID=# Your Ensign Client ID goes here +$ export ENSIGN_CLIENT_SECRET=# Your Ensign Client Secret goes here +$ go build -o publish main.go +$ ./publish --query "otters" +``` +*Note: Here the Ensign Client ID and Client Secret are retrieved from environment variables but it's also possible to specify them in [code]({{< ref "/getting-started#create-a-client" >}})* + +## Creating a subscriber + +Similarly to the `Publisher`, a `Subscriber` interface can be created from an Ensign client. Once created, the `Subscriber` allows us to read events directly from a Go channel. Create a `main.go` in the `subscribe` directory and add the following code to it. + +```golang +package main + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/rotationalio/ensign-examples/go/tweets/schemas" + api "github.com/rotationalio/ensign/pkg/api/v1beta1" + ensign "github.com/rotationalio/ensign/sdks/go" +) + +func main() { + var ( + err error + client *ensign.Client + ) + + // ENSIGN_CLIENT_ID and ENSIGN_CLIENT_SECRET environment variables must be set + if client, err = ensign.New(&ensign.Options{}); err != nil { + panic("failed to create Ensign client: " + err.Error()) + } + + // Create a subscriber from the client + var sub ensign.Subscriber + if sub, err = client.Subscribe(context.Background()); err != nil { + panic("failed to create subscriber from client: " + err.Error()) + } + defer sub.Close() + + // Create the event stream as a channel + var events <-chan *api.Event + if events, err = sub.Subscribe(); err != nil { + panic("failed to create subscribe stream: " + err.Error()) + } + + // Events are processed as they show up on the channel + for event := range events { + tweet := &schemas.Tweet{} + if err = json.Unmarshal(event.Data, tweet); err != nil { + panic("failed to unmarshal event: " + err.Error()) + } + + fmt.Printf("received tweet %s\n", tweet.ID) + fmt.Println(tweet.Text) + fmt.Println() + } +} +``` + +At this point you should be able to build and the run the subscriber in a second command window to retrieve tweet events in real time! + +```bash +$ export ENSIGN_CLIENT_ID=# Your Ensign Client ID +$ export ENSIGN_CLIENT_SECRET=# Your Ensign Client Secret +$ cd subscribe +$ go build -o subscribe main.go +$ ./subscribe +``` + +## What Next? + +Hopefully this gets you on the right track and inspires some ideas for event-driven applications. If this example were to become a real application, here are some things we might consider. + +### Event Schemas + +Remember that an Ensign event encodes a lot of metadata. When dealing with more strutured or versioned serialization formats such as protobuf, we definitely want to consider adding some logic to the subscriber to lookup the event schema in the schema registry or a local cache with the `event.Type` field. + +### Additional Topic Streams + +With Ensign it's easy to scale up by adding new topics. We might want to have different topics for error observabiity (e.g. if the Twitter API changes or schemas unexpectedly change), metrics capturing, or different types of Twitter queries. + +### Downstream Processing + +Once we have an event stream, what do we do with it? A traditional approach is to capture data into a database for persistence and to make it easy to materialize data views for application users. This is certainly possible with Ensign. However, Ensign also offers persistence of event streams, which makes it possible to perform historical queries on the streams themselves. \ No newline at end of file diff --git a/docs/content/getting-started/_index.md b/docs/content/getting-started/_index.md index 4465f8536..ad601f474 100644 --- a/docs/content/getting-started/_index.md +++ b/docs/content/getting-started/_index.md @@ -43,7 +43,7 @@ If you haven't already: In your command line, type the following to install the ensign API, SDK, and library code for Go: ```bash -go install github.com/rotationalio/ensign@latest +go get -u github.com/rotationalio/ensign/sdks/go@latest ``` ### Create a Client @@ -128,8 +128,14 @@ if err != nil { fmt.Errorf("could not create subscriber: %s", err) } -msg := sub.Subscribe() -fmt.Sprintln(msg.Data) +var events <-chan *api.Event +if events, err = sub.Subscribe(); err != nil { + panic("failed to create subscribe stream: " + err.Error()) +} + +for msg := range events { + fmt.Println(msg.Data) +} ``` ## Next Steps From af8bb1e3901b89a3415b0a24f55ed3e51aa61452 Mon Sep 17 00:00:00 2001 From: Rebecca Bilbro Date: Fri, 27 Jan 2023 17:23:02 -0500 Subject: [PATCH 2/3] minor edits --- docs/content/examples/_index.md | 4 +++- docs/content/examples/developers.md | 10 +++++----- docs/content/getting-started/_index.md | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/content/examples/_index.md b/docs/content/examples/_index.md index 389b043e2..0316204c3 100644 --- a/docs/content/examples/_index.md +++ b/docs/content/examples/_index.md @@ -10,4 +10,6 @@ bookSearchExclude: false # End-to-End Examples -This section of the documentation provides end-to-end examples using Ensign to help get you started! \ No newline at end of file +This section of the documentation provides end-to-end examples using Ensign to help get you started! + +- [Ensign for Application Developers](({{< relref "examples/developers" >}})): In this end-to-end example, see how to curate a custom Twitter feed using Ensign. Create a publisher to start emitting tweets to a topic stream in just a few minutes! See how to create one or more asynchronous subscribers that can read off the stream and process the data as needed. \ No newline at end of file diff --git a/docs/content/examples/developers.md b/docs/content/examples/developers.md index 6f95bbf96..e0007e121 100644 --- a/docs/content/examples/developers.md +++ b/docs/content/examples/developers.md @@ -32,7 +32,7 @@ $ mkdir tweets $ go mod init github.com/rotationalio/ensign-examples/go/tweets ``` -Next we'll need to install the Go SDK client and its depenencies from the GitHub [repo](https://github.com/rotationalio/ensign). In this tutorial we also use the [go-twitter](https://github.com/g8rswimmer/go-twitter) client to interact with the twitter API (although you can also create the requests yourself)! +Next we'll need to install the Go SDK client and its dependencies from the GitHub [repo](https://github.com/rotationalio/ensign). In this tutorial we also use the [go-twitter](https://github.com/g8rswimmer/go-twitter) client to interact with the twitter API (although you can also create the requests yourself)! ```bash $ go get -u github.com/rotationalio/ensign/sdks/go@latest @@ -129,7 +129,7 @@ Now that we have a data source, the next step is to create an Ensign client usin ```golang import ( -... + ... twitter "github.com/g8rswimmer/go-twitter/v2" ensign "github.com/rotationalio/ensign/sdks/go" ) @@ -246,10 +246,10 @@ If your IDE did not resolve the imports for you, you will need to specify them m ```golang import ( -... + ... api "github.com/rotationalio/ensign/pkg/api/v1beta1" mimetype "github.com/rotationalio/ensign/pkg/mimetype/v1beta1" -... + ... ) ``` @@ -398,7 +398,7 @@ Remember that an Ensign event encodes a lot of metadata. When dealing with more ### Additional Topic Streams -With Ensign it's easy to scale up by adding new topics. We might want to have different topics for error observabiity (e.g. if the Twitter API changes or schemas unexpectedly change), metrics capturing, or different types of Twitter queries. +With Ensign it's easy to scale up by adding new topics. We might want to have different topics for error observability (e.g. if the Twitter API changes or schemas unexpectedly change), metrics capturing, or different types of Twitter queries. ### Downstream Processing diff --git a/docs/content/getting-started/_index.md b/docs/content/getting-started/_index.md index ad601f474..c789730df 100644 --- a/docs/content/getting-started/_index.md +++ b/docs/content/getting-started/_index.md @@ -19,7 +19,7 @@ Ensign is a new eventing tool that make it fast, convenient, and fun to create ## Getting Started -The first step is to get an Ensign API key by visiting rotational.io/ensign. Similar to getting a developer API key for [Youtube](https://developers.google.com/youtube/v3/getting-started), [Twitter](https://developer.twitter.com/en/docs/twitter-api/getting-started/getting-access-to-the-twitter-api) or [Data.gov](https://api.data.gov/docs/api-key/), you will need an API key to use Ensign and to follow along with the rest of this Quickstart guide. +The first step is to get an Ensign API key by visiting [the sign-up page](https://rotational.io/ensign/). Similar to getting a developer API key for [Youtube](https://developers.google.com/youtube/v3/getting-started), [Twitter](https://developer.twitter.com/en/docs/twitter-api/getting-started/getting-access-to-the-twitter-api) or [Data.gov](https://api.data.gov/docs/api-key/), you will need an API key to use Ensign and to follow along with the rest of this Quickstart guide. Your key consists of two parts, a `ClientID` and a `ClientSecret`. The `ClientID` uniquely identifies you, and the `ClientSecret` proves that you have permission to create and access event data. From 84d4e96fb9edb5ffe568ded3717a915f556b49ff Mon Sep 17 00:00:00 2001 From: Rebecca Bilbro Date: Sun, 29 Jan 2023 10:23:25 -0500 Subject: [PATCH 3/3] try to fix landing page CI build failure --- pkg/ensign/server.go | 4 ++-- .../common/MaintenanceMode/MaintenanceMode.stories.tsx | 4 ++-- .../{MaintenaceMode.tsx => MaintenanceMode.tsx} | 6 +++--- .../src/components/common/MaintenanceMode/index.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) rename web/beacon-app/src/components/common/MaintenanceMode/{MaintenaceMode.tsx => MaintenanceMode.tsx} (82%) diff --git a/pkg/ensign/server.go b/pkg/ensign/server.go index 071badbe6..060b388df 100644 --- a/pkg/ensign/server.go +++ b/pkg/ensign/server.go @@ -178,8 +178,8 @@ func (s *Server) UnaryInterceptors() []grpc.UnaryServerInterceptor { // If we're in maintenance mode only return the maintenance mode interceptor and // the panic recovery interceptor (just in case). Otherwise continue to build chain. - if maintenace := interceptors.UnaryMaintenance(s.conf); maintenace != nil { - opts = append(opts, maintenace) + if maintenance := interceptors.UnaryMaintenance(s.conf); maintenance != nil { + opts = append(opts, maintenance) opts = append(opts, interceptors.UnaryRecovery(s.conf.Sentry)) return opts } diff --git a/web/beacon-app/src/components/common/MaintenanceMode/MaintenanceMode.stories.tsx b/web/beacon-app/src/components/common/MaintenanceMode/MaintenanceMode.stories.tsx index b704889b7..02da86668 100644 --- a/web/beacon-app/src/components/common/MaintenanceMode/MaintenanceMode.stories.tsx +++ b/web/beacon-app/src/components/common/MaintenanceMode/MaintenanceMode.stories.tsx @@ -1,11 +1,11 @@ import { Meta, Story } from '@storybook/react'; -import MaintenaceMode from './MaintenaceMode'; +import MaintenanceMode from './MaintenanceMode'; export default { title: '/component/MaintenanceMode', } as Meta; -const Template: Story = (args) => ; +const Template: Story = (args) => ; export const Default = Template.bind({}); Default.args = {}; \ No newline at end of file diff --git a/web/beacon-app/src/components/common/MaintenanceMode/MaintenaceMode.tsx b/web/beacon-app/src/components/common/MaintenanceMode/MaintenanceMode.tsx similarity index 82% rename from web/beacon-app/src/components/common/MaintenanceMode/MaintenaceMode.tsx rename to web/beacon-app/src/components/common/MaintenanceMode/MaintenanceMode.tsx index 467c8242f..c70ea39ec 100644 --- a/web/beacon-app/src/components/common/MaintenanceMode/MaintenaceMode.tsx +++ b/web/beacon-app/src/components/common/MaintenanceMode/MaintenanceMode.tsx @@ -1,11 +1,11 @@ import { memo } from 'react'; import busyOtters from '/src/assets/images/busy-sea-otters.png'; -function MaintenaceMode() { +function MaintenanceMode() { return (

- Ensign is temporarily undergoing scheduled maintenace. We'll be back online shortly. See + Ensign is temporarily undergoing scheduled maintenance. We'll be back online shortly. See our{' '} operating status @@ -23,4 +23,4 @@ function MaintenaceMode() { ); } -export default memo(MaintenaceMode) \ No newline at end of file +export default memo(MaintenanceMode) \ No newline at end of file diff --git a/web/beacon-app/src/components/common/MaintenanceMode/index.ts b/web/beacon-app/src/components/common/MaintenanceMode/index.ts index 643b6fee6..7ea0a2ffc 100644 --- a/web/beacon-app/src/components/common/MaintenanceMode/index.ts +++ b/web/beacon-app/src/components/common/MaintenanceMode/index.ts @@ -1 +1 @@ -export { default as MaintenanceMode } from './MaintenaceMode' \ No newline at end of file +export { default as MaintenanceMode } from './MaintenanceMode' \ No newline at end of file