summaryrefslogtreecommitdiffstats
path: root/raddb/mods-config/sql/main/postgresql/process-radacct.sql
blob: a454369249af04e7da8201522e5f8cb360e64d88 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
#  -*- text -*-
#
#  main/postgresql/process-radacct.sql -- Schema extensions for processing radacct entries
#
#  $Id$

--  ---------------------------------
--  - Per-user data usage over time -
--  ---------------------------------
--
--  An extension to the standard schema to hold per-user data usage statistics
--  for arbitrary periods.
--
--  The data_usage_by_period table is populated by periodically calling the
--  fr_new_data_usage_period stored procedure.
--
--  This table can be queried in various ways to produce reports of aggregate
--  data use over time. For example, if the fr_new_data_usage_period SP is
--  invoked one per day just after midnight, to produce usage data with daily
--  granularity, then a reasonably accurate monthly bandwidth summary for a
--  given user could be obtained by queriing this table with:
--
--      SELECT
--          TO_CHAR(period_start, 'YYYY-Month') AS month,
--          TRUNC(SUM(acctinputoctets)/1000/1000/1000,9) AS gb_in,
--          TRUNC(SUM(acctoutputoctets)/1000/1000/1000,9) AS gb_out
--      FROM
--          data_usage_by_period
--      WHERE
--          username='bob' AND
--          period_end IS NOT NULL
--      GROUP BY
--          month;
--
--           month      |    gb_in    |    gb_out
--      ----------------+-------------+--------------
--       2019-July      | 5.782279231 | 50.545664824
--       2019-August    | 4.230543344 | 48.523096424
--       2019-September | 4.847360599 | 48.631835488
--       2019-October   | 6.456763254 | 51.686231937
--       2019-November  | 6.362537735 | 52.385710572
--       2019-December  | 4.301524442 | 50.762240277
--       2020-January   | 5.436280545 | 49.067775286
--      (7 rows)
--
CREATE TABLE data_usage_by_period (
    username text,
    period_start timestamp with time zone,
    period_end timestamp with time zone,
    acctinputoctets bigint,
    acctoutputoctets bigint
);
ALTER TABLE data_usage_by_period ADD CONSTRAINT data_usage_by_period_pkey PRIMARY KEY (username, period_start);
CREATE INDEX data_usage_by_period_pkey_period_end ON data_usage_by_period(period_end);


--
--  Stored procedure that when run with some arbitrary frequency, say
--  once per day by cron, will process the recent radacct entries to extract
--  time-windowed data containing acct{input,output}octets ("data usage") per
--  username, per period.
--
--  Each invocation will create new rows in the data_usage_by_period tables
--  containing the data used by each user since the procedure was last invoked.
--  The intervals do not need to be identical but care should be taken to
--  ensure that the start/end of each period aligns well with any intended
--  reporting intervals.
--
--  It can be invoked by running:
--
--      SELECT fr_new_data_usage_period();
--
--
CREATE OR REPLACE FUNCTION fr_new_data_usage_period ()
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE v_start timestamp;
DECLARE v_end timestamp;
BEGIN

    SELECT COALESCE(MAX(period_end) + INTERVAL '1 SECOND', TO_TIMESTAMP(0)) INTO v_start FROM data_usage_by_period;
    SELECT DATE_TRUNC('second',CURRENT_TIMESTAMP) INTO v_end;

    --
    -- Add the data usage for the sessions that were active in the current
    -- period to the table. Include all sessions that finished since the start
    -- of this period as well as those still ongoing.
    --
    INSERT INTO data_usage_by_period (username, period_start, period_end, acctinputoctets, acctoutputoctets)
    SELECT *
    FROM (
        SELECT
            username,
            v_start,
            v_end,
            SUM(acctinputoctets) AS acctinputoctets,
            SUM(acctoutputoctets) AS acctoutputoctets
        FROM ((
            SELECT
                username, acctinputoctets, acctoutputoctets
            FROM
                radacct
            WHERE
                acctstoptime > v_start
        ) UNION ALL (
            SELECT
                username, acctinputoctets, acctoutputoctets
            FROM
                radacct
            WHERE
                acctstoptime IS NULL
        )) AS a
        GROUP BY
            username
    ) AS s
    ON CONFLICT ON CONSTRAINT data_usage_by_period_pkey
    DO UPDATE
        SET
            acctinputoctets = data_usage_by_period.acctinputoctets + EXCLUDED.acctinputoctets,
            acctoutputoctets = data_usage_by_period.acctoutputoctets + EXCLUDED.acctoutputoctets,
            period_end = v_end;

    --
    -- Create an open-ended "next period" for all ongoing sessions and carry a
    -- negative value of their data usage to avoid double-accounting when we
    -- process the next period. Their current data usage has already been
    -- allocated to the current and possibly previous periods.
    --
    INSERT INTO data_usage_by_period (username, period_start, period_end, acctinputoctets, acctoutputoctets)
    SELECT *
    FROM (
        SELECT
            username,
            v_end + INTERVAL '1 SECOND',
            NULL::timestamp,
            0 - SUM(acctinputoctets),
            0 - SUM(acctoutputoctets)
        FROM
            radacct
        WHERE
            acctstoptime IS NULL
        GROUP BY
            username
    ) AS s;

END
$$;


--  ------------------------------------------------------
--  - "Lightweight" Accounting-On/Off strategy resources -
--  ------------------------------------------------------
--
--  The following resources are for use only when the "lightweight"
--  Accounting-On/Off strategy is enabled in queries.conf.
--
--  Instead of bulk closing the radacct sessions belonging to a reloaded NAS,
--  this strategy leaves them open and records the NAS reload time in the
--  nasreload table.
--
--  Where applicable, the onus is on the administator to:
--
--    * Consider the nas reload times when deriving a list of
--      active/inactive sessions, and when determining the duration of sessions
--      interrupted by a NAS reload. (Refer to the view below.)
--
--    * Close the affected sessions out of band. (Refer to the SP below.)
--
--
--  The radacct_with_reloads view presents the radacct table with two additional
--  columns: acctstoptime_with_reloads and acctsessiontime_with_reloads
--
--  Where the session isn't closed (acctstoptime IS NULL), yet it started before
--  the last reload of the NAS (radacct.acctstarttime < nasreload.reloadtime),
--  the derived columns are set based on the reload time of the NAS (effectively
--  the point in time that the session was interrupted.)
--
CREATE VIEW radacct_with_reloads AS
SELECT
    a.*,
    COALESCE(a.AcctStopTime,
        CASE WHEN a.AcctStartTime < n.ReloadTime THEN n.ReloadTime END
    ) AS AcctStopTime_With_Reloads,
    COALESCE(a.AcctSessionTime,
        CASE WHEN a.AcctStopTime IS NULL AND a.AcctStartTime < n.ReloadTime THEN
            EXTRACT(EPOCH FROM (n.ReloadTime - a.AcctStartTime))
        END
    ) AS AcctSessionTime_With_Reloads
FROM radacct a
LEFT OUTER JOIN nasreload n USING (nasipaddress);


--
--  It may be desirable to periodically "close" radacct sessions belonging to a
--  reloaded NAS, replicating the "bulk close" Accounting-On/Off behaviour,
--  just not in real time.
--
--  The fr_radacct_close_after_reload SP will set radacct.acctstoptime to
--  nasreload.reloadtime, calculate the corresponding radacct.acctsessiontime,
--  and set acctterminatecause to "NAS reboot" for interrupted sessions. It
--  does so in batches, which avoids long-lived locks on the affected rows.
--
--  It can be invoked as follows:
--
--      CALL fr_radacct_close_after_reload();
--
--  Note: This SP requires PostgreSQL >= 11 which was the first version to
--  introduce PROCEDUREs which permit transaction control. This allows COMMIT
--  to be called to incrementally apply successive batch updates prior to the
--  end of the procedure. Prior to version 11 there exists only FUNCTIONs that
--  execute atomically. You can convert this procedure to a function, but by
--  doing so you are really no better off than performing a single,
--  long-running bulk update.
--
--  Note: This SP walks radacct in strides of v_batch_size. It will typically
--  skip closed and ongoing sessions at a rate significantly faster than
--  500,000 rows per second and process batched updates faster than 25,000
--  orphaned sessions per second. If this isn't fast enough then you should
--  really consider using a custom schema that includes partitioning by
--  nasipaddress or acct{start,stop}time.
--
CREATE OR REPLACE PROCEDURE fr_radacct_close_after_reload ()
LANGUAGE plpgsql
AS $$

DECLARE v_a bigint;
DECLARE v_z bigint;
DECLARE v_updated bigint DEFAULT 0;
DECLARE v_last_report bigint DEFAULT 0;
DECLARE v_now bigint;
DECLARE v_last boolean DEFAULT false;
DECLARE v_rowcount integer;

--
--  This works for many circumstances
--
DECLARE v_batch_size CONSTANT integer := 2500;

BEGIN

    SELECT MIN(RadAcctId) INTO v_a FROM radacct WHERE AcctStopTime IS NULL;

    LOOP

        v_z := NULL;
        SELECT RadAcctId INTO v_z FROM radacct WHERE RadAcctId > v_a ORDER BY RadAcctId OFFSET v_batch_size LIMIT 1;

        IF v_z IS NULL THEN
            SELECT MAX(RadAcctId) INTO v_z FROM radacct;
            v_last := true;
        END IF;

        UPDATE radacct a
        SET
            AcctStopTime = n.reloadtime,
            AcctSessionTime = EXTRACT(EPOCH FROM (n.ReloadTime - a.AcctStartTime)),
            AcctTerminateCause = 'NAS reboot'
        FROM nasreload n
        WHERE
            a.NASIPAddress = n.NASIPAddress
            AND RadAcctId BETWEEN v_a AND v_z
            AND AcctStopTime IS NULL
            AND AcctStartTime < n.ReloadTime;

        GET DIAGNOSTICS v_rowcount := ROW_COUNT;
        v_updated := v_updated + v_rowcount;

        COMMIT;     -- Make the update visible

        v_a := v_z + 1;

        --
        --  Periodically report how far we've got
        --
        SELECT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) INTO v_now;
        IF v_last_report != v_now OR v_last THEN
            RAISE NOTICE 'RadAcctID: %; Sessions closed: %', v_z, v_updated;
            v_last_report := v_now;
        END IF;

        EXIT WHEN v_last;

    END LOOP;

END
$$;