redis_exchange 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ --[[
2
+ migrate_orders_batch.lua
3
+ Process a batch of price levels for precision update.
4
+ Reads orders from old queues, converts them, and appends to TMP queues/books.
5
+
6
+ KEYS:
7
+ 1. market:{symbol}
8
+ 2. book:{symbol}:{side}:TMP
9
+ 3. volume:{symbol}:{side}:TMP
10
+ 4. orders:{symbol} (for events)
11
+
12
+ ARGV:
13
+ 1. symbol
14
+ 2. side (BUY/SELL)
15
+ 3. new_price_scale
16
+ 4. new_quantity_scale
17
+ 5. timestamp
18
+ 6...N. price_members (strings) to process
19
+
20
+ Returns:
21
+ JSON object { processed_orders: N, processed_levels: M }
22
+ ]]
23
+
24
+ local cjson = cjson
25
+
26
+ local market_key = KEYS[1]
27
+ local book_tmp_key = KEYS[2]
28
+ local volume_tmp_key = KEYS[3]
29
+ local orders_key = KEYS[4]
30
+
31
+ local symbol = ARGV[1]
32
+ local side = ARGV[2]
33
+ local new_price_scale = tonumber(ARGV[3])
34
+ local new_quantity_scale = tonumber(ARGV[4])
35
+ local timestamp = tonumber(ARGV[5])
36
+
37
+ -- Helper: Convert HGETALL to map
38
+ local function to_map(array)
39
+ local map = {}
40
+ for i = 1, #array, 2 do
41
+ map[array[i]] = array[i + 1]
42
+ end
43
+ return map
44
+ end
45
+
46
+ local function as_number(value)
47
+ return tonumber(value) or 0
48
+ end
49
+
50
+ local function append_ledger(user_id, asset, bucket, delta, reason, ts)
51
+ if delta == 0 then return end
52
+ local seq = redis.call('INCR', 'ledger_seq:' .. user_id .. ':' .. asset)
53
+ local ledger_biz_id = 'PRECISION:' .. symbol .. ':' .. ts .. ':' .. seq
54
+ redis.call('XADD', 'account_ledger', 'MAXLEN', '~', 1000000, '*',
55
+ 'user_id', user_id,
56
+ 'asset', asset,
57
+ 'bucket', bucket,
58
+ 'amount', tostring(delta),
59
+ 'reason', reason,
60
+ 'ts', tostring(ts),
61
+ 'sequence', tostring(seq),
62
+ 'biz_id', ledger_biz_id
63
+ )
64
+ end
65
+
66
+ -- Load Market Config (for old scales)
67
+ local market = to_map(redis.call('HGETALL', market_key))
68
+ if next(market) == nil then
69
+ return cjson.encode({ error = 'UNKNOWN_MARKET' })
70
+ end
71
+
72
+ local base_asset = market['base_asset']
73
+ local quote_asset = market['quote_asset']
74
+ local old_price_scale = tonumber(market['price_scale'])
75
+ local old_quantity_scale = tonumber(market['quantity_scale'])
76
+ local maker_fee_bps = tonumber(market['maker_fee_bps'] or '0')
77
+
78
+ -- Load Asset Factors
79
+ local base_asset_map = to_map(redis.call('HGETALL', 'asset:' .. base_asset))
80
+ local quote_asset_map = to_map(redis.call('HGETALL', 'asset:' .. quote_asset))
81
+ local base_balance_scale = tonumber(base_asset_map['balance_scale'] or '0')
82
+ local quote_balance_scale = tonumber(quote_asset_map['balance_scale'] or '0')
83
+
84
+ local function compute_factor(balance_scale, market_scale)
85
+ if market_scale <= 0 then return 0 end
86
+ local div = math.floor(balance_scale / market_scale)
87
+ if (div * market_scale) ~= balance_scale then return 0 end
88
+ return div
89
+ end
90
+
91
+ local new_base_factor = compute_factor(base_balance_scale, new_quantity_scale)
92
+ local new_quote_factor = compute_factor(quote_balance_scale, new_price_scale)
93
+
94
+ if new_base_factor == 0 or new_quote_factor == 0 then
95
+ return cjson.encode({ error = 'ASSET_SCALE_MISMATCH' })
96
+ end
97
+
98
+ local processed_orders_count = 0
99
+ local processed_levels_count = 0
100
+ local is_buy = (side == 'BUY')
101
+
102
+ -- Iterate over provided price members
103
+ for i = 6, #ARGV do
104
+ local old_price_member = ARGV[i]
105
+ local old_price = as_number(old_price_member)
106
+ local old_queue_key = 'queue:' .. symbol .. ':' .. side .. ':' .. old_price_member
107
+
108
+ -- Calculate New Price
109
+ local new_price = math.floor(old_price * new_price_scale / old_price_scale)
110
+ local new_price_member = tostring(new_price)
111
+ local new_queue_key_tmp = 'queue:' .. symbol .. ':' .. side .. ':' .. new_price_member .. ':TMP'
112
+
113
+ local orders = redis.call('LRANGE', old_queue_key, 0, -1)
114
+ -- Note: We do NOT delete the old queue here.
115
+ -- We leave old data intact until finalize step to be safe,
116
+ -- OR we can delete it now to save space?
117
+ -- Safety first: If script fails midway, we want to be able to retry.
118
+ -- But if we don't delete, re-running this batch will duplicate data in TMP.
119
+ -- Assumption: The caller handles idempotency or we clear TMP before starting.
120
+ -- Actually, let's delete the old queue key as we process it.
121
+ -- If we crash, we are in a halfway state anyway (Maintenance Mode).
122
+ redis.call('DEL', old_queue_key)
123
+
124
+ for _, order_id in ipairs(orders) do
125
+ local order_key = 'order:' .. order_id
126
+ local order = to_map(redis.call('HGETALL', order_key))
127
+
128
+ if next(order) then
129
+ processed_orders_count = processed_orders_count + 1
130
+ local old_qty = as_number(order['quantity'])
131
+ local old_rem = as_number(order['remaining'])
132
+ local old_reserved_base = as_number(order['reserved_base'])
133
+ local old_reserved_quote = as_number(order['reserved_quote'])
134
+ local user_id = order['user_id']
135
+
136
+ local new_qty = math.floor(old_qty * new_quantity_scale / old_quantity_scale)
137
+ local new_rem = math.floor(old_rem * new_quantity_scale / old_quantity_scale)
138
+
139
+ local new_reserved_base = 0
140
+ local new_reserved_quote = 0
141
+
142
+ if side == 'SELL' then
143
+ new_reserved_base = new_rem * new_base_factor
144
+ else
145
+ local quote_val = math.floor((new_price * new_rem) / new_quantity_scale)
146
+ local fee = math.floor((quote_val * maker_fee_bps) / 10000)
147
+ local total_quote = quote_val + fee
148
+ new_reserved_quote = total_quote * new_quote_factor
149
+ end
150
+
151
+ local refund_base = old_reserved_base - new_reserved_base
152
+ local refund_quote = old_reserved_quote - new_reserved_quote
153
+
154
+ if refund_base > 0 then
155
+ local bal_key = 'balance:' .. user_id
156
+ redis.call('HINCRBY', bal_key, base_asset .. ':reserved', -refund_base)
157
+ redis.call('HINCRBY', bal_key, base_asset .. ':available', refund_base)
158
+ append_ledger(user_id, base_asset, 'available', refund_base, 'DUST_REFUND', timestamp)
159
+ append_ledger(user_id, base_asset, 'reserved', -refund_base, 'DUST_RELEASE', timestamp)
160
+ end
161
+
162
+ if refund_quote > 0 then
163
+ local bal_key = 'balance:' .. user_id
164
+ redis.call('HINCRBY', bal_key, quote_asset .. ':reserved', -refund_quote)
165
+ redis.call('HINCRBY', bal_key, quote_asset .. ':available', refund_quote)
166
+ append_ledger(user_id, quote_asset, 'available', refund_quote, 'DUST_REFUND', timestamp)
167
+ append_ledger(user_id, quote_asset, 'reserved', -refund_quote, 'DUST_RELEASE', timestamp)
168
+ end
169
+
170
+ if new_rem > 0 then
171
+ redis.call('HMSET', order_key,
172
+ 'price', new_price_member,
173
+ 'quantity', tostring(new_qty),
174
+ 'remaining', tostring(new_rem),
175
+ 'reserved_base', tostring(new_reserved_base),
176
+ 'reserved_quote', tostring(new_reserved_quote)
177
+ )
178
+ redis.call('RPUSH', new_queue_key_tmp, order_id)
179
+
180
+ -- Accumulate Volume in TMP
181
+ redis.call('HINCRBY', volume_tmp_key, new_price_member, new_rem)
182
+
183
+ -- Add to TMP Book
184
+ local score = is_buy and -new_price or new_price
185
+ redis.call('ZADD', book_tmp_key, score, new_price_member)
186
+ else
187
+ redis.call('HMSET', order_key,
188
+ 'price', new_price_member,
189
+ 'quantity', tostring(new_qty),
190
+ 'remaining', '0',
191
+ 'reserved_base', '0',
192
+ 'reserved_quote', '0',
193
+ 'status', 'FILLED'
194
+ )
195
+ redis.call('ZREM', 'user_orders:' .. user_id .. ':' .. symbol, order_id)
196
+ redis.call('SET', 'order_tombstone:' .. order_id, 'FILLED', 'EX', 86400)
197
+ redis.call('DEL', order_key)
198
+ end
199
+ end
200
+ end
201
+ processed_levels_count = processed_levels_count + 1
202
+ end
203
+
204
+ return cjson.encode({ processed_orders = processed_orders_count, processed_levels = processed_levels_count })
@@ -0,0 +1,260 @@
1
+ --[[
2
+ update_market_precision.lua
3
+ Revises all orders for a market to match new precision settings.
4
+ Refunds any "dust" amounts to user balances.
5
+
6
+ KEYS:
7
+ 1. market:{symbol}
8
+ 2. book:{symbol}:BUY
9
+ 3. book:{symbol}:SELL
10
+ 4. volume:{symbol}:BUY
11
+ 5. volume:{symbol}:SELL
12
+ 6. orders:{symbol} (for events)
13
+
14
+ ARGV:
15
+ 1. symbol
16
+ 2. new_price_scale
17
+ 3. new_quantity_scale
18
+ 4. timestamp
19
+
20
+ Returns:
21
+ JSON object { updated_orders: N, refunds: M }
22
+ ]]
23
+
24
+ local cjson = cjson
25
+
26
+ local market_key = KEYS[1]
27
+ local book_buy_key = KEYS[2]
28
+ local book_sell_key = KEYS[3]
29
+ local volume_buy_key = KEYS[4]
30
+ local volume_sell_key = KEYS[5]
31
+ local orders_key = KEYS[6]
32
+
33
+ local symbol = ARGV[1]
34
+ local new_price_scale = tonumber(ARGV[2])
35
+ local new_quantity_scale = tonumber(ARGV[3])
36
+ local timestamp = tonumber(ARGV[4])
37
+
38
+ if not (symbol and new_price_scale and new_quantity_scale and timestamp) then
39
+ return cjson.encode({ error = 'INVALID_ARGUMENTS' })
40
+ end
41
+
42
+ if new_price_scale <= 0 or new_quantity_scale <= 0 then
43
+ return cjson.encode({ error = 'INVALID_SCALE' })
44
+ end
45
+
46
+ -- Helper: Convert HGETALL to map
47
+ local function to_map(array)
48
+ local map = {}
49
+ for i = 1, #array, 2 do
50
+ map[array[i]] = array[i + 1]
51
+ end
52
+ return map
53
+ end
54
+
55
+ local function as_number(value)
56
+ return tonumber(value) or 0
57
+ end
58
+
59
+ -- Helper: Append Ledger (Copied from match_order.lua - shortened)
60
+ local function append_ledger(user_id, asset, bucket, delta, reason, ts)
61
+ if delta == 0 then return end
62
+ local seq = redis.call('INCR', 'ledger_seq:' .. user_id .. ':' .. asset)
63
+ local ledger_biz_id = 'PRECISION:' .. symbol .. ':' .. ts .. ':' .. seq
64
+ redis.call('XADD', 'account_ledger', 'MAXLEN', '~', 1000000, '*',
65
+ 'user_id', user_id,
66
+ 'asset', asset,
67
+ 'bucket', bucket,
68
+ 'amount', tostring(delta),
69
+ 'reason', reason,
70
+ 'ts', tostring(ts),
71
+ 'sequence', tostring(seq),
72
+ 'biz_id', ledger_biz_id
73
+ )
74
+ end
75
+
76
+ -- Load Market
77
+ local market = to_map(redis.call('HGETALL', market_key))
78
+ if next(market) == nil then
79
+ return cjson.encode({ error = 'UNKNOWN_MARKET' })
80
+ end
81
+
82
+ local base_asset = market['base_asset']
83
+ local quote_asset = market['quote_asset']
84
+ local old_price_scale = tonumber(market['price_scale'])
85
+ local old_quantity_scale = tonumber(market['quantity_scale'])
86
+ local maker_fee_bps = tonumber(market['maker_fee_bps'] or '0')
87
+
88
+ -- Load Asset Configs for Factors
89
+ local base_asset_map = to_map(redis.call('HGETALL', 'asset:' .. base_asset))
90
+ local quote_asset_map = to_map(redis.call('HGETALL', 'asset:' .. quote_asset))
91
+ local base_balance_scale = tonumber(base_asset_map['balance_scale'] or '0')
92
+ local quote_balance_scale = tonumber(quote_asset_map['balance_scale'] or '0')
93
+
94
+ -- Validate Asset Scales
95
+ local function compute_factor(balance_scale, market_scale)
96
+ if market_scale <= 0 then return 0 end
97
+ local div = math.floor(balance_scale / market_scale)
98
+ if (div * market_scale) ~= balance_scale then return 0 end
99
+ return div
100
+ end
101
+
102
+ local new_base_factor = compute_factor(base_balance_scale, new_quantity_scale)
103
+ local new_quote_factor = compute_factor(quote_balance_scale, new_price_scale)
104
+
105
+ if new_base_factor == 0 or new_quote_factor == 0 then
106
+ return cjson.encode({ error = 'ASSET_SCALE_MISMATCH_WITH_NEW_CONFIG' })
107
+ end
108
+
109
+ -- Update Market Config
110
+ redis.call('HSET', market_key, 'price_scale', new_price_scale, 'quantity_scale', new_quantity_scale)
111
+
112
+ local updated_count = 0
113
+ local sides = { 'BUY', 'SELL' }
114
+ local queues_to_rename = {} -- { {old_key, new_key, is_tmp} }
115
+ -- Actually we use TMP keys for new queues
116
+
117
+ local function process_side(side)
118
+ local is_buy = (side == 'BUY')
119
+ local book_key = is_buy and book_buy_key or book_sell_key
120
+ local volume_key = is_buy and volume_buy_key or volume_sell_key
121
+
122
+ -- Read all levels
123
+ local levels = redis.call('ZRANGE', book_key, 0, -1, 'WITHSCORES')
124
+
125
+ -- Clear old book and volume
126
+ redis.call('DEL', book_key)
127
+ redis.call('DEL', volume_key)
128
+
129
+ -- We will rebuild book/volume in memory then write
130
+ local new_book = {} -- price -> score
131
+ local new_volume = {} -- price -> volume
132
+
133
+ for i = 1, #levels, 2 do
134
+ local old_price_member = levels[i]
135
+ local old_price = as_number(old_price_member) -- This is the price string/member
136
+ -- Note: Score is also available but we use member as source of truth for price value
137
+
138
+ local old_queue_key = 'queue:' .. symbol .. ':' .. side .. ':' .. old_price_member
139
+
140
+ -- Calculate New Price
141
+ -- Logic: new_p = floor(old_p * new_scale / old_scale)
142
+ local new_price = math.floor(old_price * new_price_scale / old_price_scale)
143
+ local new_price_member = tostring(new_price)
144
+
145
+ -- Iterate Orders
146
+ local orders = redis.call('LRANGE', old_queue_key, 0, -1)
147
+ redis.call('DEL', old_queue_key) -- Delete old queue
148
+
149
+ local new_queue_key_tmp = 'queue:' .. symbol .. ':' .. side .. ':' .. new_price_member .. ':TMP'
150
+
151
+ for _, order_id in ipairs(orders) do
152
+ local order_key = 'order:' .. order_id
153
+ local order = to_map(redis.call('HGETALL', order_key))
154
+
155
+ if next(order) then
156
+ updated_count = updated_count + 1
157
+ local old_qty = as_number(order['quantity'])
158
+ local old_rem = as_number(order['remaining'])
159
+ local old_reserved_base = as_number(order['reserved_base'])
160
+ local old_reserved_quote = as_number(order['reserved_quote'])
161
+ local user_id = order['user_id']
162
+
163
+ -- Calculate New Qty/Rem
164
+ local new_qty = math.floor(old_qty * new_quantity_scale / old_quantity_scale)
165
+ local new_rem = math.floor(old_rem * new_quantity_scale / old_quantity_scale)
166
+
167
+ -- Calculate New Reserved
168
+ local new_reserved_base = 0
169
+ local new_reserved_quote = 0
170
+
171
+ if side == 'SELL' then
172
+ -- SELL: Reserve Base = rem * base_factor
173
+ new_reserved_base = new_rem * new_base_factor
174
+ else
175
+ -- BUY: Reserve Quote = floor(price * rem / qty_scale) + fee
176
+ -- fee = floor(quote * bps / 10000)
177
+ local quote_val = math.floor((new_price * new_rem) / new_quantity_scale)
178
+ local fee = math.floor((quote_val * maker_fee_bps) / 10000)
179
+ local total_quote = quote_val + fee
180
+ new_reserved_quote = total_quote * new_quote_factor
181
+ end
182
+
183
+ -- Refunds
184
+ local refund_base = old_reserved_base - new_reserved_base
185
+ local refund_quote = old_reserved_quote - new_reserved_quote
186
+
187
+ if refund_base > 0 then
188
+ local bal_key = 'balance:' .. user_id
189
+ redis.call('HINCRBY', bal_key, base_asset .. ':reserved', -refund_base)
190
+ redis.call('HINCRBY', bal_key, base_asset .. ':available', refund_base)
191
+ append_ledger(user_id, base_asset, 'available', refund_base, 'DUST_REFUND', timestamp)
192
+ append_ledger(user_id, base_asset, 'reserved', -refund_base, 'DUST_RELEASE', timestamp)
193
+ end
194
+
195
+ if refund_quote > 0 then
196
+ local bal_key = 'balance:' .. user_id
197
+ redis.call('HINCRBY', bal_key, quote_asset .. ':reserved', -refund_quote)
198
+ redis.call('HINCRBY', bal_key, quote_asset .. ':available', refund_quote)
199
+ append_ledger(user_id, quote_asset, 'available', refund_quote, 'DUST_REFUND', timestamp)
200
+ append_ledger(user_id, quote_asset, 'reserved', -refund_quote, 'DUST_RELEASE', timestamp)
201
+ end
202
+
203
+ -- Update Order
204
+ if new_rem > 0 then
205
+ redis.call('HMSET', order_key,
206
+ 'price', new_price_member,
207
+ 'quantity', tostring(new_qty),
208
+ 'remaining', tostring(new_rem),
209
+ 'reserved_base', tostring(new_reserved_base),
210
+ 'reserved_quote', tostring(new_reserved_quote)
211
+ )
212
+ -- Push to new queue (TMP)
213
+ redis.call('RPUSH', new_queue_key_tmp, order_id)
214
+
215
+ -- Accumulate Volume
216
+ new_volume[new_price_member] = (new_volume[new_price_member] or 0) + new_rem
217
+
218
+ -- Mark this TMP key for renaming later (deduplicate)
219
+ queues_to_rename[new_queue_key_tmp] = 'queue:' .. symbol .. ':' .. side .. ':' .. new_price_member
220
+ else
221
+ -- Filled (Dust eliminated remaining)
222
+ redis.call('HMSET', order_key,
223
+ 'price', new_price_member,
224
+ 'quantity', tostring(new_qty),
225
+ 'remaining', '0',
226
+ 'reserved_base', '0',
227
+ 'reserved_quote', '0',
228
+ 'status', 'FILLED'
229
+ )
230
+ -- Remove from User Index
231
+ local user_idx = 'user_orders:' .. user_id .. ':' .. symbol
232
+ redis.call('ZREM', user_idx, order_id)
233
+ -- Tombstone
234
+ redis.call('SET', 'order_tombstone:' .. order_id, 'FILLED', 'EX', 86400)
235
+ redis.call('DEL', order_key)
236
+ end
237
+ end
238
+ end
239
+ end
240
+
241
+ -- Write Book and Volume
242
+ for price_str, vol in pairs(new_volume) do
243
+ if vol > 0 then
244
+ local price_val = tonumber(price_str)
245
+ local score = is_buy and -price_val or price_val
246
+ redis.call('ZADD', book_key, score, price_str)
247
+ redis.call('HSET', volume_key, price_str, vol)
248
+ end
249
+ end
250
+ end
251
+
252
+ process_side('BUY')
253
+ process_side('SELL')
254
+
255
+ -- Rename TMP queues to Real queues
256
+ for tmp_key, real_key in pairs(queues_to_rename) do
257
+ redis.call('RENAME', tmp_key, real_key)
258
+ end
259
+
260
+ return cjson.encode({ updated_orders = updated_count })
@@ -0,0 +1,234 @@
1
+ #!/bin/bash
2
+ # Export balances and ledger sequences from a Redis RDB file to CSV.
3
+ #
4
+ # Outputs:
5
+ # - balances_{SNAPSHOT_ID}_{TIMESTAMP}.csv: User balances (available, reserved).
6
+ # - ledger_sequences_{SNAPSHOT_ID}_{TIMESTAMP}.csv: Ledger sequence numbers.
7
+ #
8
+ # Dependencies:
9
+ # - rdb-cli (included in bin/)
10
+ # - jq (brew install jq / apt install jq)
11
+ #
12
+ # Usage:
13
+ # ./script/export_rdb_data.sh [options] [path/to/dump.rdb]
14
+ #
15
+ # Options:
16
+ # -h, --host <host> Redis host (default: 127.0.0.1)
17
+ # -p, --port <port> Redis port (default: 6379)
18
+ # -a, --password <pwd> Redis password
19
+ # -u, --url <url> Redis URL (e.g. redis://[:password@]host:port[/db])
20
+ #
21
+
22
+ set -e
23
+
24
+ REDIS_HOST="127.0.0.1"
25
+ REDIS_PORT="6379"
26
+ REDIS_PASS=""
27
+ REDIS_URL=""
28
+ USE_REMOTE=false
29
+ POSITIONAL_ARGS=()
30
+
31
+ while [[ $# -gt 0 ]]; do
32
+ case $1 in
33
+ -h|--host)
34
+ REDIS_HOST="$2"
35
+ USE_REMOTE=true
36
+ shift 2
37
+ ;;
38
+ -p|--port)
39
+ REDIS_PORT="$2"
40
+ USE_REMOTE=true
41
+ shift 2
42
+ ;;
43
+ -a|--password)
44
+ REDIS_PASS="$2"
45
+ USE_REMOTE=true
46
+ shift 2
47
+ ;;
48
+ -u|--url)
49
+ REDIS_URL="$2"
50
+ USE_REMOTE=true
51
+ shift 2
52
+ ;;
53
+ *)
54
+ POSITIONAL_ARGS+=("$1")
55
+ shift
56
+ ;;
57
+ esac
58
+ done
59
+
60
+ set -- "${POSITIONAL_ARGS[@]}"
61
+
62
+ if [ ${#POSITIONAL_ARGS[@]} -gt 0 ]; then
63
+ RDB_FILE="${POSITIONAL_ARGS[0]}"
64
+ if [ ! -f "$RDB_FILE" ]; then
65
+ echo "Error: RDB file '$RDB_FILE' not found."
66
+ exit 1
67
+ fi
68
+ CLEANUP_RDB=false
69
+ elif [ "$USE_REMOTE" = true ]; then
70
+ if ! command -v redis-cli &> /dev/null; then
71
+ echo "Error: redis-cli not found. Cannot dump from remote Redis."
72
+ exit 1
73
+ fi
74
+
75
+ # Create tmp directory if it doesn't exist
76
+ mkdir -p tmp
77
+
78
+ RDB_FILE="tmp/dump_$(date +%s).rdb"
79
+
80
+ if [ -n "$REDIS_URL" ]; then
81
+ echo "Dumping data from Redis URL to ${RDB_FILE}..."
82
+
83
+ # Check if URL contains password but empty username (e.g. redis://:password@host...)
84
+ # This causes redis-cli to fail with "invalid uri scheme" or similar errors depending on version
85
+ # If detected, inject 'default' as username
86
+ if [[ "$REDIS_URL" =~ redis://:[^@]+@ ]]; then
87
+ echo " Detected empty username with password in REDIS_URL. Injecting 'default' username..."
88
+ REDIS_URL="${REDIS_URL/redis:\/\/:/redis:\/\/default:}"
89
+ fi
90
+
91
+ CMD="redis-cli -u $REDIS_URL --rdb $RDB_FILE"
92
+ else
93
+ echo "Dumping data from ${REDIS_HOST}:${REDIS_PORT} to ${RDB_FILE}..."
94
+
95
+ CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
96
+ if [ -n "$REDIS_PASS" ]; then
97
+ CMD="$CMD -a $REDIS_PASS"
98
+ fi
99
+ CMD="$CMD --rdb $RDB_FILE"
100
+ fi
101
+
102
+ $CMD
103
+
104
+ if [ ! -f "$RDB_FILE" ]; then
105
+ echo "Error: Failed to dump RDB file from remote."
106
+ exit 1
107
+ fi
108
+ CLEANUP_RDB=true
109
+ else
110
+ # Default fallback
111
+ RDB_FILE="dump.rdb"
112
+ if [ ! -f "$RDB_FILE" ]; then
113
+ echo "Error: RDB file '$RDB_FILE' not found."
114
+ echo "Usage: $0 [options] [path/to/dump.rdb]"
115
+ exit 1
116
+ fi
117
+ CLEANUP_RDB=false
118
+ fi
119
+
120
+ # Check for rdb-cli (librdb)
121
+ if [ -n "$RDB_CLI_CMD" ]; then
122
+ RDB_CMD="$RDB_CLI_CMD"
123
+ elif [ -f "./bin/rdb-cli" ]; then
124
+ RDB_CMD="./bin/rdb-cli"
125
+ elif command -v rdb-cli &> /dev/null; then
126
+ RDB_CMD="rdb-cli"
127
+ else
128
+ echo "Error: 'rdb-cli' command not found. Please ensure bin/rdb-cli exists."
129
+ exit 1
130
+ fi
131
+
132
+ if ! command -v jq &> /dev/null; then
133
+ echo "Error: 'jq' command not found. Please install jq."
134
+ exit 1
135
+ fi
136
+
137
+ echo "Processing $RDB_FILE..."
138
+
139
+ # Create tmp directory if it doesn't exist
140
+ mkdir -p tmp
141
+
142
+ echo "Exporting balances to tmp/balances.csv..."
143
+ echo "user_id,asset,available,reserved" > tmp/balances.csv
144
+
145
+ # rdb-cli json outputs: [{"key": value, "key2": value2}] (Array of objects, usually one per DB)
146
+ # We filter for keys starting with "balance:" using -k
147
+ $RDB_CMD "$RDB_FILE" -k "balance:*" json > tmp/balances_raw.json
148
+ # echo "Raw JSON:"
149
+ # cat tmp/balances_raw.json
150
+
151
+ cat tmp/balances_raw.json | jq -r '
152
+ .[] | to_entries[] |
153
+ .key as $k |
154
+ .value | to_entries |
155
+ map(
156
+ (.key | split(":")) as $p |
157
+ {asset: $p[0], bucket: $p[1], val: .value}
158
+ ) |
159
+ group_by(.asset)[] |
160
+ {
161
+ user: ($k | split(":")[1]),
162
+ asset: .[0].asset,
163
+ avail: (map(select(.bucket=="available"))[0].val // "0"),
164
+ reserved: (map(select(.bucket=="reserved"))[0].val // "0")
165
+ } |
166
+ "\(.user),\(.asset),\(.avail),\(.reserved)"
167
+ ' >> tmp/balances.csv
168
+
169
+ echo " -> Wrote $(wc -l < tmp/balances.csv) lines (including header)"
170
+
171
+ echo "Exporting ledger sequences to tmp/ledger_sequences.csv..."
172
+ echo "user_id,asset,sequence" > tmp/ledger_sequences.csv
173
+
174
+ # Filter for keys starting with "ledger_seq:"
175
+ $RDB_CMD "$RDB_FILE" -k "ledger_seq:*" json > tmp/ledger_seq_raw.json
176
+
177
+ cat tmp/ledger_seq_raw.json | jq -r '
178
+ .[] | to_entries[] |
179
+ (.key | split(":")) as $p |
180
+ "\( $p[1] ),\( $p[2] ),\( .value )"
181
+ ' >> tmp/ledger_sequences.csv
182
+
183
+ echo " -> Wrote $(wc -l < tmp/ledger_sequences.csv) lines (including header)"
184
+
185
+ # 3. Export Snapshot ID (Last Entry ID from account_ledger)
186
+ echo "Exporting snapshot ID from account_ledger..."
187
+
188
+ # rdb-cli output for stream varies based on Redis stream structure
189
+ # Try to get the last entry from account_ledger stream
190
+ # Note: rdb-cli outputs invalid JSON ("account_ledger":) for empty streams, so we need to fix that
191
+ $RDB_CMD "$RDB_FILE" -k "account_ledger" json | sed 's/"account_ledger":$/&null/' > tmp/stream_raw.json
192
+
193
+ # Debug: show the raw JSON structure (commented out)
194
+ # cat stream_raw.json
195
+
196
+ # Actual JSON structure from rdb-cli for Redis streams:
197
+ # [{"account_ledger": {"entries": [{"id": "...", "items": {"ts": "...", ...}}, ...]}}]
198
+ # Note: Some databases may have empty account_ledger, so we need to filter them out
199
+ SNAPSHOT_DATA=$(cat tmp/stream_raw.json | jq -r '
200
+ try (
201
+ .[] |
202
+ select(has("account_ledger") and .account_ledger != null and (.account_ledger.entries // null) != null) |
203
+ .account_ledger.entries |
204
+ select(length > 0) |
205
+ last | "\(.id) \(.items.ts // 0)"
206
+ ) catch "0-0 0"
207
+ ')
208
+
209
+ # Take the last non-empty line (from the database with actual data)
210
+ SNAPSHOT_LINE=$(echo "$SNAPSHOT_DATA" | grep -v '^0-0 0$' | tail -n 1)
211
+ if [ -z "$SNAPSHOT_LINE" ]; then
212
+ SNAPSHOT_LINE="0-0 0"
213
+ fi
214
+
215
+ SNAPSHOT_ID=$(echo "$SNAPSHOT_LINE" | awk '{print $1}')
216
+ SNAPSHOT_TS=$(echo "$SNAPSHOT_LINE" | awk '{print $2}')
217
+
218
+ if [ -z "$SNAPSHOT_ID" ] || [ "$SNAPSHOT_ID" == "null" ]; then
219
+ SNAPSHOT_ID="0-0"
220
+ fi
221
+
222
+ mv tmp/balances.csv tmp/balances_${SNAPSHOT_ID}_${SNAPSHOT_TS}.csv
223
+ mv tmp/ledger_sequences.csv tmp/ledger_sequences_${SNAPSHOT_ID}_${SNAPSHOT_TS}.csv
224
+ echo "Snapshot ID: $SNAPSHOT_ID, Timestamp: $SNAPSHOT_TS"
225
+ echo "Renamed balances.csv to tmp/balances_${SNAPSHOT_ID}_${SNAPSHOT_TS}.csv"
226
+ echo "Renamed ledger_sequences.csv to tmp/ledger_sequences_${SNAPSHOT_ID}_${SNAPSHOT_TS}.csv"
227
+
228
+ # Cleanup
229
+ if [ "$CLEANUP_RDB" = true ]; then
230
+ echo "Cleaning up temporary RDB file $RDB_FILE..."
231
+ rm "$RDB_FILE"
232
+ fi
233
+
234
+ rm -f tmp/balances_raw.json tmp/ledger_seq_raw.json tmp/stream_raw.json