Skip to content

Commit 8c780a5

Browse files
committed
Use file-based store instead of Postgres DB
- Store metadata in the file system instead of a Postgres DB. PR #24 demonstrates that a large user of memory is the Postgres store, specifically building the query for large batch-style requrests. This could be investigated further, but a quick fix is simply to use a file-based store. There is no need to build a query, and files can be looked up quickly using their filename.
1 parent 7753b36 commit 8c780a5

15 files changed

Lines changed: 425 additions & 73 deletions

File tree

cabal-nix.project

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ packages:
22
./metadata-lib
33
./metadata-server
44
./metadata-store-postgres
5+
./metadata-store-file
56
./metadata-webhook
67
./metadata-validator-github
78
./token-metadata-creator

cabal.project

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ packages:
55
./metadata-server
66
./metadata-webhook
77
./metadata-store-postgres
8+
./metadata-store-file
89
./metadata-validator-github
910
./token-metadata-creator
1011

metadata-lib/src/Test/Cardano/Metadata/Generators.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ complexType =
7575
<*> Gen.map (Range.linear 0 20) ((,) <$> key <*> val)
7676

7777
complexKey :: MonadGen m => m ComplexKey
78-
complexKey = unSubject <$> subject
78+
complexKey = Gen.text (Range.linear 1 255) Gen.alphaNum
7979

8080
complexKeyVals :: MonadGen m => m [(ComplexKey, ComplexType)]
8181
complexKeyVals = Gen.list (Range.linear 0 20) ((,) <$> complexKey <*> complexType)

metadata-server/metadata-server.cabal

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ executable metadata-server
1919
, lens
2020
, lens-aeson
2121
, metadata-lib
22-
, metadata-store-postgres
22+
, metadata-store-file
2323
, monad-logger
2424
, mtl
2525
, persistent-postgresql

metadata-server/src/Config.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ module Config where
22

33
import Options.Applicative
44

5-
import Cardano.Metadata.Store.Postgres.Config
5+
import Cardano.Metadata.Store.File.Config
66
( Opts, parseOpts )
77

88
opts :: ParserInfo Opts

metadata-server/src/Main.hs

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,25 +21,19 @@ import qualified Options.Applicative as Opt
2121

2222
import Cardano.Metadata.Server
2323
( webApp )
24-
import qualified Cardano.Metadata.Store.Postgres as Store
25-
import Cardano.Metadata.Store.Postgres.Config
26-
( Opts (..), pgConnectionString )
24+
import qualified Cardano.Metadata.Store.File as Store
25+
import Cardano.Metadata.Store.File.Config
26+
( Opts (..) )
2727
import Config
2828
( opts )
2929

3030
main :: IO ()
3131
main = do
32-
options@(Opts { optDbConnections = numDbConns
33-
, optDbMetadataTableName = tableName
32+
options@(Opts { optMetadataLocation = folder
3433
, optServerPort = port
3534
}) <- Opt.execParser opts
3635

37-
let pgConnString = pgConnectionString options
38-
putStrLn $ "Connecting to database using connection string: " <> BC.unpack pgConnString
39-
runStdoutLoggingT $
40-
Postgresql.withPostgresqlPool pgConnString numDbConns $ \pool -> liftIO $ do
41-
putStrLn $ "Initializing table '" <> tableName <> "'."
42-
intf <- Store.postgresStore pool (T.pack tableName)
43-
44-
putStrLn $ "Metadata server is starting on port " <> show port <> "."
45-
liftIO $ Warp.run port (webApp intf)
36+
putStrLn $ "Using file store at: " <> folder
37+
intf <- Store.fileStore folder
38+
putStrLn $ "Metadata server is starting on port " <> show port <> "."
39+
liftIO $ Warp.run port (webApp intf)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
cabal-version: >=1.10
2+
name: metadata-store-file
3+
version: 0.1.0.0
4+
author: Samuel Evans-Powell
5+
maintainer: mail@sevanspowell.net
6+
build-type: Simple
7+
extra-source-files: CHANGELOG
8+
9+
library
10+
hs-source-dirs: src
11+
12+
exposed-modules: Cardano.Metadata.Store.File
13+
Cardano.Metadata.Store.File.Config
14+
15+
build-depends: aeson
16+
, base
17+
, bytestring
18+
, containers
19+
, directory
20+
, metadata-lib
21+
, mtl
22+
, filepath
23+
, optparse-applicative
24+
, safe-exceptions
25+
, scientific
26+
, text
27+
, unordered-containers
28+
, warp
29+
30+
ghc-options: -Wall
31+
-Wincomplete-record-updates
32+
-Wincomplete-uni-patterns
33+
-Wincomplete-patterns
34+
-Wredundant-constraints
35+
-Wpartial-fields
36+
-Wcompat
37+
-rtsopts
38+
39+
test-suite integration-tests
40+
hs-source-dirs: test
41+
main-is: Main.hs
42+
type: exitcode-stdio-1.0
43+
44+
build-depends: base >=4.12 && <5
45+
, HUnit
46+
, QuickCheck
47+
, aeson
48+
, aeson-pretty
49+
, base
50+
, bytestring
51+
, casing
52+
, containers
53+
, directory
54+
, hedgehog
55+
, hspec
56+
, http-client
57+
, lens
58+
, lens-aeson
59+
, metadata-lib
60+
, metadata-store-file
61+
, monad-logger
62+
, mtl
63+
, raw-strings-qq
64+
, resource-pool
65+
, safe-exceptions
66+
, scientific
67+
, servant
68+
, servant-client
69+
, servant-server
70+
, smallcheck
71+
, tagged
72+
, tasty
73+
, tasty-hedgehog
74+
, tasty-hspec
75+
, tasty-hunit
76+
, tasty-quickcheck
77+
, text
78+
, unordered-containers
79+
, wai
80+
, warp
81+
82+
ghc-options: -Wall
83+
-Wincomplete-record-updates
84+
-Wincomplete-uni-patterns
85+
-Wincomplete-patterns
86+
-Wredundant-constraints
87+
-Wpartial-fields
88+
-Wcompat
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
{-# LANGUAGE DeriveAnyClass #-}
2+
{-# LANGUAGE FlexibleContexts #-}
3+
{-# LANGUAGE FlexibleInstances #-}
4+
{-# LANGUAGE GADTs #-}
5+
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
6+
{-# LANGUAGE IncoherentInstances #-}
7+
{-# LANGUAGE MultiParamTypeClasses #-}
8+
{-# LANGUAGE OverloadedStrings #-}
9+
{-# LANGUAGE QuasiQuotes #-}
10+
{-# LANGUAGE RecordWildCards #-}
11+
{-# LANGUAGE ScopedTypeVariables #-}
12+
{-# LANGUAGE TemplateHaskell #-}
13+
{-# LANGUAGE TupleSections #-}
14+
{-# LANGUAGE TypeFamilies #-}
15+
16+
module Cardano.Metadata.Store.File
17+
( read
18+
, write
19+
, update
20+
, delete
21+
, empty
22+
, toList
23+
, init
24+
, fileStore
25+
) where
26+
27+
import Cardano.Metadata.Store.Types
28+
import Control.Exception.Safe
29+
import Control.Monad.Reader
30+
import Data.Aeson
31+
( FromJSON, FromJSONKey, ToJSON, ToJSONKey )
32+
import qualified Data.Aeson as Aeson
33+
import qualified Data.Aeson.Encoding.Internal as Aeson
34+
import qualified Data.Aeson.Types as Aeson
35+
import Data.Coerce
36+
( coerce )
37+
import Data.Maybe
38+
( catMaybes, fromMaybe )
39+
import Data.Text
40+
( Text )
41+
import qualified Data.Text as T
42+
import qualified Data.Text.Lazy as TL
43+
import qualified Data.Text.Lazy.Encoding as TLE
44+
import Prelude hiding
45+
( init, read )
46+
import System.Directory
47+
import System.FilePath.Posix
48+
( takeFileName )
49+
50+
data PostgresKeyValueException = UniqueKeyConstraintViolated
51+
| FailedToDecodeJSONValue String Text
52+
deriving (Eq, Show, Exception)
53+
54+
data KeyValue k v = KeyValue { _kvFolder :: FilePath }
55+
56+
init
57+
:: FilePath
58+
-- ^ Folder containing metadata entries
59+
-> IO (KeyValue k v)
60+
-- ^ Resulting key-value store
61+
init = pure . KeyValue
62+
63+
fileStore
64+
:: ( ToJSONKey k
65+
, ToJSON v
66+
, FromJSONKey k
67+
, FromJSON v
68+
)
69+
=> FilePath
70+
-- ^ Folder containing metadata entries
71+
-> IO (StoreInterface k v)
72+
fileStore folder = do
73+
let kvs = KeyValue folder
74+
pure $ StoreInterface (\k -> read k kvs)
75+
(\ks -> readBatch ks kvs)
76+
(\k v -> write k v kvs)
77+
(\k -> delete k kvs)
78+
(\f k -> update f k kvs)
79+
(toList kvs)
80+
(empty kvs)
81+
82+
-- | Ensure file path is within folder.
83+
safeFilePath :: ToJSONKey k => KeyValue k v -> k -> FilePath
84+
safeFilePath (KeyValue folder) k =
85+
let
86+
-- Disallow user to enter a sub-directory or a parent directory by
87+
-- limiting the requested path to a file name. I.e. "../x.txt" and
88+
-- "inner/x.txt" are normalised to "x.txt" to restrict the user
89+
-- from looking outside the specified folder.
90+
raw :: FilePath
91+
raw = takeFileName . T.unpack $ toJSONKeyText k
92+
in
93+
folder <> "/" <> raw
94+
95+
withFileIfExists :: ToJSONKey k => KeyValue k v -> k -> (FilePath -> IO r) -> IO (Maybe r)
96+
withFileIfExists kvs k f = do
97+
let safe = safeFilePath kvs k
98+
exists <- doesFileExist safe
99+
if exists
100+
then do
101+
r <- f safe
102+
pure $ Just r
103+
else pure Nothing
104+
105+
read :: (ToJSONKey k, FromJSON v) => k -> KeyValue k v -> IO (Maybe v)
106+
read k kvs = do
107+
withFileIfExists kvs k $ \safe ->
108+
Aeson.eitherDecodeFileStrict' safe
109+
>>= (\v -> handleJSONDecodeError (T.pack safe) v)
110+
111+
readBatch :: (ToJSONKey k, FromJSON v) => [k] -> KeyValue k v -> IO [v]
112+
readBatch [] _kvs = pure []
113+
readBatch ks kvs = fmap catMaybes $ forM ks (\k -> read k kvs)
114+
115+
write :: (ToJSONKey k, ToJSON v) => k -> v -> KeyValue k v -> IO ()
116+
write k v kvs =
117+
let
118+
safe = safeFilePath kvs k
119+
in
120+
Aeson.encodeFile safe v
121+
122+
delete :: ToJSONKey k => k -> KeyValue k v -> IO ()
123+
delete k kvs =
124+
fromMaybe () <$> withFileIfExists kvs k removeFile
125+
126+
update :: (ToJSONKey k, ToJSON v, FromJSON v) => (v -> Maybe v) -> k -> KeyValue k v -> IO ()
127+
update fv k kvs = do
128+
mv <- read k kvs
129+
case mv of
130+
Nothing -> pure ()
131+
Just v -> case fv v of
132+
Nothing -> delete k kvs
133+
Just newValue -> write k newValue kvs
134+
135+
toList :: (ToJSONKey k, FromJSONKey k, FromJSON v) => KeyValue k v -> IO [(k, v)]
136+
toList kvs@(KeyValue folder) = do
137+
ks <- fmap (fmap T.pack) $ listDirectory folder
138+
forM ks $ \kText -> do
139+
k <- handleJSONDecodeError kText $ decodeJSONKey kText
140+
mV <- read k kvs
141+
pure $ maybe (error $ "Unable to find file with name '" <> (T.unpack $ toJSONKeyText k) <> "'") (k,) mV
142+
143+
empty :: (FromJSONKey k, ToJSONKey k) => KeyValue k v -> IO ()
144+
empty kvs@(KeyValue folder) = do
145+
ks <- fmap (fmap T.pack) $ listDirectory folder
146+
void . forM ks $ \kText -> do
147+
k <- handleJSONDecodeError undefined $ decodeJSONKey kText
148+
delete k kvs
149+
150+
handleJSONDecodeError :: Text -> Either String a -> IO a
151+
handleJSONDecodeError t = either (\err -> throw $ FailedToDecodeJSONValue err t) pure
152+
153+
toJSONKeyText :: ToJSONKey k => k -> Text
154+
toJSONKeyText k =
155+
case Aeson.toJSONKey of
156+
Aeson.ToJSONKeyText f _ -> f k
157+
Aeson.ToJSONKeyValue _ f -> TL.toStrict $ TLE.decodeUtf8 $ Aeson.encodingToLazyByteString $ f k
158+
159+
decodeJSONKey :: FromJSONKey k => Text -> Either String k
160+
decodeJSONKey t = case Aeson.fromJSONKey of
161+
Aeson.FromJSONKeyCoerce -> pure $ coerce t
162+
Aeson.FromJSONKeyText f -> pure $ f t
163+
Aeson.FromJSONKeyTextParser p -> Aeson.parseEither p t
164+
Aeson.FromJSONKeyValue pv -> do
165+
(v :: Aeson.Value) <- Aeson.eitherDecode (TLE.encodeUtf8 . TL.fromStrict $ t)
166+
Aeson.parseEither pv v
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module Cardano.Metadata.Store.File.Config where
2+
3+
import qualified Network.Wai.Handler.Warp as Warp
4+
import Options.Applicative
5+
6+
data Opts = Opts
7+
{ optMetadataLocation :: FilePath
8+
, optServerPort :: Warp.Port
9+
}
10+
deriving (Eq, Show)
11+
12+
parseOpts :: Parser Opts
13+
parseOpts = Opts
14+
<$> strOption (long "folder" <> metavar "FOLDER" <> help "Folder containing the metadata entries")
15+
<*> option auto (short 'p' <> long "port" <> metavar "PORT" <> showDefault <> value 8080 <> help "Port to run the metadata web server on")

0 commit comments

Comments
 (0)