• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

realm / realm-core / thomas.goyne_120

20 Nov 2023 07:46PM UTC coverage: 92.066% (+0.4%) from 91.699%
thomas.goyne_120

push

Evergreen

web-flow
Fix client reset cycle detection for PBS recovery errors (#7149)

Tracking that a client reset was in progress was done in the same write
transaction as the recovery operation, so if recovery failed the tracking was
rolled back too. This worked for FLX due to that codepath committing before
beginning recovery.

85328 of 169122 branches covered (0.0%)

30 of 30 new or added lines in 3 files covered. (100.0%)

169 existing lines in 18 files now uncovered.

233147 of 253239 relevant lines covered (92.07%)

5776222.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.25
/src/realm/sync/subscriptions.cpp
1
/*************************************************************************
2
 *
3
 * Copyright 2021 Realm, Inc.
4
 *
5
 * Licensed under the Apache License, Version 2.0 (the "License");
6
 * you may not use this file except in compliance with the License.
7
 * You may obtain a copy of the License at
8
 *
9
 * http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 *
17
 **************************************************************************/
18

19
#include "realm/sync/subscriptions.hpp"
20

21
#include "external/json/json.hpp"
22

23
#include "realm/data_type.hpp"
24
#include "realm/keys.hpp"
25
#include "realm/list.hpp"
26
#include "realm/sort_descriptor.hpp"
27
#include "realm/sync/noinst/sync_metadata_schema.hpp"
28
#include "realm/table.hpp"
29
#include "realm/table_view.hpp"
30
#include "realm/transaction.hpp"
31
#include "realm/util/flat_map.hpp"
32

33
#include <algorithm>
34
#include <initializer_list>
35
#include <stdexcept>
36

37
namespace realm::sync {
38
namespace {
39
// Schema version history:
40
//   v2: Initial public beta.
41

42
constexpr static int c_flx_schema_version = 2;
43
constexpr static std::string_view c_flx_subscription_sets_table("flx_subscription_sets");
44
constexpr static std::string_view c_flx_subscriptions_table("flx_subscriptions");
45

46
constexpr static std::string_view c_flx_sub_sets_state_field("state");
47
constexpr static std::string_view c_flx_sub_sets_version_field("version");
48
constexpr static std::string_view c_flx_sub_sets_error_str_field("error");
49
constexpr static std::string_view c_flx_sub_sets_subscriptions_field("subscriptions");
50
constexpr static std::string_view c_flx_sub_sets_snapshot_version_field("snapshot_version");
51

52
constexpr static std::string_view c_flx_sub_id_field("id");
53
constexpr static std::string_view c_flx_sub_created_at_field("created_at");
54
constexpr static std::string_view c_flx_sub_updated_at_field("updated_at");
55
constexpr static std::string_view c_flx_sub_name_field("name");
56
constexpr static std::string_view c_flx_sub_object_class_field("object_class");
57
constexpr static std::string_view c_flx_sub_query_str_field("query");
58

59
using OptionalString = util::Optional<std::string>;
60

61
enum class SubscriptionStateForStorage : int64_t {
62
    // The subscription set has been persisted locally but has not been acknowledged by the server yet.
63
    Pending = 1,
64
    // The server is currently sending the initial state that represents this subscription set to the client.
65
    Bootstrapping = 2,
66
    // This subscription set is the active subscription set that is currently being synchronized with the server.
67
    Complete = 3,
68
    // An error occurred while processing this subscription set on the server. Check error_str() for details.
69
    Error = 4,
70
    // The last bootstrap message containing the initial state for this subscription set has been received. The
71
    // client is awaiting a mark message to mark this subscription as fully caught up to history.
72
    AwaitingMark = 6,
73
};
74

75
SubscriptionSet::State state_from_storage(int64_t value)
76
{
8,286✔
77
    switch (static_cast<SubscriptionStateForStorage>(value)) {
8,286✔
78
        case SubscriptionStateForStorage::Pending:
5,222✔
79
            return SubscriptionSet::State::Pending;
5,222✔
80
        case SubscriptionStateForStorage::Bootstrapping:
100✔
81
            return SubscriptionSet::State::Bootstrapping;
100✔
82
        case SubscriptionStateForStorage::AwaitingMark:
1,130✔
83
            return SubscriptionSet::State::AwaitingMark;
1,130✔
84
        case SubscriptionStateForStorage::Complete:
1,812✔
85
            return SubscriptionSet::State::Complete;
1,812✔
86
        case SubscriptionStateForStorage::Error:
22✔
87
            return SubscriptionSet::State::Error;
22✔
88
        default:
✔
89
            throw std::runtime_error(util::format("Invalid state for SubscriptionSet stored on disk: %1", value));
×
90
    }
8,286✔
91
}
8,286✔
92

93
int64_t state_to_storage(SubscriptionSet::State state)
94
{
28,108✔
95
    switch (state) {
28,108✔
96
        case SubscriptionSet::State::Pending:
10,210✔
97
            return static_cast<int64_t>(SubscriptionStateForStorage::Pending);
10,210✔
98
        case SubscriptionSet::State::Bootstrapping:
9,202✔
99
            return static_cast<int64_t>(SubscriptionStateForStorage::Bootstrapping);
9,202✔
100
        case SubscriptionSet::State::AwaitingMark:
4,566✔
101
            return static_cast<int64_t>(SubscriptionStateForStorage::AwaitingMark);
4,566✔
102
        case SubscriptionSet::State::Complete:
4,112✔
103
            return static_cast<int64_t>(SubscriptionStateForStorage::Complete);
4,112✔
104
        case SubscriptionSet::State::Error:
18✔
105
            return static_cast<int64_t>(SubscriptionStateForStorage::Error);
18✔
106
        default:
✔
107
            REALM_UNREACHABLE();
108
    }
28,108✔
109
}
28,108✔
110

111
size_t state_to_order(SubscriptionSet::State needle)
112
{
4,220✔
113
    using State = SubscriptionSet::State;
4,220✔
114
    switch (needle) {
4,220✔
115
        case State::Uncommitted:
12✔
116
            return 0;
12✔
117
        case State::Pending:
722✔
118
            return 1;
722✔
119
        case State::Bootstrapping:
40✔
120
            return 2;
40✔
121
        case State::AwaitingMark:
624✔
122
            return 3;
624✔
123
        case State::Complete:
2,818✔
124
            return 4;
2,818✔
125
        case State::Error:
✔
126
            return 5;
×
127
        case State::Superseded:
4✔
128
            return 6;
4✔
129
    }
×
130
    REALM_UNREACHABLE();
131
}
×
132

133
template <typename T, typename Predicate>
134
void splice_if(std::list<T>& src, std::list<T>& dst, Predicate pred)
135
{
3,418✔
136
    for (auto it = src.begin(); it != src.end();) {
5,282✔
137
        if (pred(*it)) {
1,864!
138
            dst.splice(dst.end(), src, it++);
704✔
139
        }
704✔
140
        else {
1,160✔
141
            ++it;
1,160✔
142
        }
1,160✔
143
    }
1,864✔
144
}
3,418✔
145

146
} // namespace
147

148
Subscription::Subscription(const SubscriptionStore* parent, Obj obj)
149
    : id(obj.get<ObjectId>(parent->m_sub_id))
150
    , created_at(obj.get<Timestamp>(parent->m_sub_created_at))
151
    , updated_at(obj.get<Timestamp>(parent->m_sub_updated_at))
152
    , name(obj.is_null(parent->m_sub_name) ? OptionalString(util::none)
153
                                           : OptionalString{obj.get<String>(parent->m_sub_name)})
154
    , object_class_name(obj.get<String>(parent->m_sub_object_class_name))
155
    , query_string(obj.get<String>(parent->m_sub_query_str))
156
{
5,712✔
157
}
5,712✔
158

159
Subscription::Subscription(util::Optional<std::string> name, std::string object_class_name, std::string query_str)
160
    : id(ObjectId::gen())
161
    , created_at(std::chrono::system_clock::now())
162
    , updated_at(created_at)
163
    , name(std::move(name))
164
    , object_class_name(std::move(object_class_name))
165
    , query_string(std::move(query_str))
166
{
918✔
167
}
918✔
168

169

170
SubscriptionSet::SubscriptionSet(std::weak_ptr<const SubscriptionStore> mgr, const Transaction& tr, const Obj& obj,
171
                                 MakingMutableCopy making_mutable_copy)
172
    : m_mgr(mgr)
173
    , m_cur_version(tr.get_version())
174
    , m_version(obj.get_primary_key().get_int())
175
    , m_obj_key(obj.get_key())
176
{
8,350✔
177
    REALM_ASSERT(obj.is_valid());
8,350✔
178
    if (!making_mutable_copy) {
8,350✔
179
        load_from_database(obj);
7,180✔
180
    }
7,180✔
181
}
8,350✔
182

183
SubscriptionSet::SubscriptionSet(std::weak_ptr<const SubscriptionStore> mgr, int64_t version, SupersededTag)
184
    : m_mgr(mgr)
185
    , m_version(version)
186
    , m_state(State::Superseded)
187
{
12✔
188
}
12✔
189

190
void SubscriptionSet::load_from_database(const Obj& obj)
191
{
7,180✔
192
    auto mgr = get_flx_subscription_store(); // Throws
7,180✔
193

4,900✔
194
    m_state = state_from_storage(obj.get<int64_t>(mgr->m_sub_set_state));
7,180✔
195
    m_error_str = obj.get<String>(mgr->m_sub_set_error_str);
7,180✔
196
    m_snapshot_version = static_cast<DB::version_type>(obj.get<int64_t>(mgr->m_sub_set_snapshot_version));
7,180✔
197
    auto sub_list = obj.get_linklist(mgr->m_sub_set_subscriptions);
7,180✔
198
    m_subs.clear();
7,180✔
199
    for (size_t idx = 0; idx < sub_list.size(); ++idx) {
12,892✔
200
        m_subs.push_back(Subscription(mgr.get(), sub_list.get_object(idx)));
5,712✔
201
    }
5,712✔
202
}
7,180✔
203

204
std::shared_ptr<const SubscriptionStore> SubscriptionSet::get_flx_subscription_store() const
205
{
13,906✔
206
    if (auto mgr = m_mgr.lock()) {
13,906✔
207
        return mgr;
13,904✔
208
    }
13,904✔
209
    throw std::logic_error("Active SubscriptionSet without a SubscriptionStore");
2✔
210
}
2✔
211

212
int64_t SubscriptionSet::version() const
213
{
10,852✔
214
    return m_version;
10,852✔
215
}
10,852✔
216

217
DB::version_type SubscriptionSet::snapshot_version() const
218
{
1,104✔
219
    return m_snapshot_version;
1,104✔
220
}
1,104✔
221

222
SubscriptionSet::State SubscriptionSet::state() const
223
{
5,682✔
224
    return m_state;
5,682✔
225
}
5,682✔
226

227
StringData SubscriptionSet::error_str() const
228
{
1,336✔
229
    if (m_error_str.empty()) {
1,336✔
230
        return StringData{};
1,322✔
231
    }
1,322✔
232
    return m_error_str;
14✔
233
}
14✔
234

235
size_t SubscriptionSet::size() const
236
{
244✔
237
    return m_subs.size();
244✔
238
}
244✔
239

240
const Subscription& SubscriptionSet::at(size_t index) const
241
{
28✔
242
    return m_subs.at(index);
28✔
243
}
28✔
244

245
SubscriptionSet::const_iterator SubscriptionSet::begin() const
246
{
3,498✔
247
    return m_subs.begin();
3,498✔
248
}
3,498✔
249

250
SubscriptionSet::const_iterator SubscriptionSet::end() const
251
{
4,420✔
252
    return m_subs.end();
4,420✔
253
}
4,420✔
254

255
const Subscription* SubscriptionSet::find(StringData name) const
256
{
54✔
257
    for (auto&& sub : *this) {
80✔
258
        if (sub.name == name)
80✔
259
            return &sub;
44✔
260
    }
80✔
261
    return nullptr;
34✔
262
}
54✔
263

264
const Subscription* SubscriptionSet::find(const Query& query) const
265
{
38✔
266
    const auto query_desc = query.get_description();
38✔
267
    const auto table_name = Group::table_name_to_class_name(query.get_table()->get_name());
38✔
268
    for (auto&& sub : *this) {
46✔
269
        if (sub.object_class_name == table_name && sub.query_string == query_desc)
46✔
270
            return &sub;
38✔
271
    }
46✔
272
    return nullptr;
24✔
273
}
38✔
274

275
MutableSubscriptionSet::MutableSubscriptionSet(std::weak_ptr<const SubscriptionStore> mgr, TransactionRef tr, Obj obj,
276
                                               MakingMutableCopy making_mutable_copy)
277
    : SubscriptionSet(mgr, *tr, obj, making_mutable_copy)
278
    , m_tr(std::move(tr))
279
    , m_obj(std::move(obj))
504✔
280
    , m_old_state(state())
504✔
281
{
1,786✔
282
}
1,786✔
283

828✔
284
void MutableSubscriptionSet::check_is_mutable() const
828✔
285
{
2,318✔
286
    if (m_tr->get_transact_stage() != DB::transact_Writing) {
2,318✔
287
        throw WrongTransactionState("Not a write transaction");
834✔
288
    }
6✔
289
}
2,318✔
290

291
// This uses the 'swap and pop' idiom to run in constant time.
292
// The iterator returned is:
293
//  1. end(), if the last subscription is removed
294
//  2. same iterator it is passed (but pointing to the last subscription in set), otherwise
10✔
295
MutableSubscriptionSet::iterator MutableSubscriptionSet::erase(const_iterator it)
10✔
296
{
38✔
297
    check_is_mutable();
38✔
298
    REALM_ASSERT(it != end());
32✔
299
    if (it == std::prev(m_subs.end())) {
32✔
300
        m_subs.pop_back();
16✔
301
        return end();
18✔
302
    }
12✔
303
    auto back = std::prev(m_subs.end());
22✔
304
    // const_iterator to iterator in constant time (See https://stackoverflow.com/a/10669041)
22✔
305
    auto iterator = m_subs.erase(it, it);
22✔
306
    std::swap(*iterator, *back);
22✔
307
    m_subs.pop_back();
22✔
308
    return iterator;
16✔
309
}
16✔
310

2✔
311
bool MutableSubscriptionSet::erase(StringData name)
2✔
312
{
8✔
313
    check_is_mutable();
8✔
314
    auto ptr = find(name);
6✔
315
    if (!ptr)
8✔
316
        return false;
4✔
317
    auto it = m_subs.begin() + (ptr - &m_subs.front());
6✔
318
    erase(it);
6✔
319
    return true;
4✔
320
}
4✔
321

6✔
322
bool MutableSubscriptionSet::erase(const Query& query)
6✔
323
{
18✔
324
    check_is_mutable();
18✔
325
    auto ptr = find(query);
12✔
326
    if (!ptr)
18✔
327
        return false;
6✔
328
    auto it = m_subs.begin() + (ptr - &m_subs.front());
18✔
329
    erase(it);
18✔
330
    return true;
12✔
331
}
12✔
332

4✔
333
bool MutableSubscriptionSet::erase_by_class_name(StringData object_class_name)
334
{
14✔
335
    // TODO: Use std::erase_if when switching to C++20.
14✔
336
    auto it = std::remove_if(m_subs.begin(), m_subs.end(), [&object_class_name](const Subscription& sub) {
24✔
337
        return sub.object_class_name == object_class_name;
22✔
338
    });
22✔
339
    auto erased = end() - it;
12✔
340
    m_subs.erase(it, end());
12✔
341
    return erased > 0;
8✔
342
}
8✔
343

2✔
344
bool MutableSubscriptionSet::erase_by_id(ObjectId id)
4✔
345
{
10✔
346
    auto it = std::find_if(m_subs.begin(), m_subs.end(), [&id](const Subscription& sub) -> bool {
14✔
347
        return sub.id == id;
12✔
348
    });
10✔
349
    if (it == end()) {
6✔
350
        return false;
4✔
351
    }
4✔
352
    erase(it);
6✔
353
    return true;
4✔
354
}
4✔
355

118✔
356
void MutableSubscriptionSet::clear()
118✔
357
{
306✔
358
    check_is_mutable();
306✔
359
    m_subs.clear();
188✔
360
}
188✔
361

222✔
362
void MutableSubscriptionSet::insert_sub(const Subscription& sub)
222✔
363
{
636✔
364
    check_is_mutable();
636✔
365
    m_subs.push_back(sub);
414✔
366
}
414✔
367

368
std::pair<SubscriptionSet::iterator, bool>
369
MutableSubscriptionSet::insert_or_assign_impl(iterator it, util::Optional<std::string> name,
408✔
370
                                              std::string object_class_name, std::string query_str)
408✔
371
{
904✔
372
    check_is_mutable();
508✔
373
    if (it != end()) {
508✔
374
        auto& sub = m_subs[it - begin()];
32✔
375
        sub.object_class_name = std::move(object_class_name);
32✔
376
        sub.query_string = std::move(query_str);
20✔
377
        sub.updated_at = Timestamp{std::chrono::system_clock::now()};
32✔
378

32✔
379
        return {it, false};
416✔
380
    }
416✔
381
    it = m_subs.insert(m_subs.end(),
476✔
382
                       Subscription(std::move(name), std::move(object_class_name), std::move(query_str)));
872✔
383

872✔
384
    return {it, true};
476✔
385
}
476✔
386

387
std::pair<SubscriptionSet::iterator, bool> MutableSubscriptionSet::insert_or_assign(std::string_view name,
14✔
388
                                                                                    const Query& query)
14✔
389
{
94✔
390
    auto table_name = Group::table_name_to_class_name(query.get_table()->get_name());
86✔
391
    auto query_str = query.get_description();
86✔
392
    auto it = std::find_if(begin(), end(), [&](const Subscription& sub) {
86✔
393
        return sub.name == name;
60✔
394
    });
74✔
395

94✔
396
    return insert_or_assign_impl(it, std::string{name}, std::move(table_name), std::move(query_str));
80✔
397
}
80✔
398

394✔
399
std::pair<SubscriptionSet::iterator, bool> MutableSubscriptionSet::insert_or_assign(const Query& query)
394✔
400
{
810✔
401
    auto table_name = Group::table_name_to_class_name(query.get_table()->get_name());
508✔
402
    auto query_str = query.get_description();
508✔
403
    auto it = std::find_if(begin(), end(), [&](const Subscription& sub) {
508✔
404
        return (!sub.name && sub.object_class_name == table_name && sub.query_string == query_str);
112✔
405
    });
506✔
406

810✔
407
    return insert_or_assign_impl(it, util::none, std::move(table_name), std::move(query_str));
416✔
408
}
416✔
409

62✔
410
void MutableSubscriptionSet::import(const SubscriptionSet& src_subs)
62✔
411
{
126✔
412
    clear();
126✔
413
    for (const Subscription& sub : src_subs) {
68✔
414
        insert_sub(sub);
68✔
415
    }
130✔
416
}
126✔
417

62✔
418
void MutableSubscriptionSet::update_state(State new_state, util::Optional<std::string_view> error_str)
419
{
1,174✔
420
    check_is_mutable();
1,190✔
421
    auto old_state = state();
1,190✔
422
    if (error_str && new_state != State::Error) {
1,190✔
423
        throw std::logic_error("Cannot supply an error message for a subscription set when state is not Error");
16✔
424
    }
425
    switch (new_state) {
1,174✔
426
        case State::Uncommitted:
504✔
427
            throw std::logic_error("cannot set subscription set state to uncommitted");
504✔
428

504✔
429
        case State::Error:
514✔
430
            if (old_state != State::Bootstrapping && old_state != State::Pending && old_state != State::Uncommitted) {
10✔
431
                throw std::logic_error(
432
                    "subscription set must be in Bootstrapping or Pending to update state to error");
6✔
433
            }
6✔
434
            if (!error_str) {
16✔
435
                throw std::logic_error("Must supply an error message when setting a subscription to the error state");
6✔
436
            }
6✔
437

16✔
438
            m_state = new_state;
10✔
439
            m_error_str = std::string{*error_str};
10✔
440
            break;
390✔
441
        case State::Bootstrapping:
406✔
442
            [[fallthrough]];
26✔
443
        case State::AwaitingMark:
806✔
444
            m_state = new_state;
426✔
445
            break;
426✔
446
        case State::Complete: {
1,118✔
447
            auto mgr = get_flx_subscription_store(); // Throws
738✔
448
            m_state = new_state;
738✔
449
            mgr->supercede_prior_to(m_tr, version());
738✔
450
            break;
1,118✔
451
        }
406✔
452
        case State::Superseded:
26✔
453
            throw std::logic_error("Cannot set a subscription to the superseded state");
454
        case State::Pending:
26✔
455
            throw std::logic_error("Cannot set subscription set to the pending state");
380✔
456
    }
1,186✔
457
}
1,186✔
458

12✔
459
MutableSubscriptionSet SubscriptionSet::make_mutable_copy() const
12✔
460
{
666✔
461
    auto mgr = get_flx_subscription_store(); // Throws
666✔
462
    return mgr->make_mutable_copy(*this);
1,046✔
463
}
666✔
464

465
void SubscriptionSet::refresh()
380✔
466
{
50✔
467
    auto mgr = get_flx_subscription_store(); // Throws
50✔
468
    if (mgr->would_refresh(m_cur_version)) {
20✔
469
        *this = mgr->get_refreshed(m_obj_key, version());
18✔
470
    }
368✔
471
}
370✔
472

350✔
473
util::Future<SubscriptionSet::State> SubscriptionSet::get_state_change_notification(State notify_when) const
350✔
474
{
422✔
475
    auto mgr = get_flx_subscription_store(); // Throws
422✔
476

422✔
477
    util::CheckedLockGuard lk(mgr->m_pending_notifications_mutex);
422✔
478
    // If we've already been superceded by another version getting completed, then we should skip registering
422✔
479
    // a notification because it may never fire.
422!
480
    if (mgr->m_min_outstanding_version > version()) {
422✔
481
        return util::Future<State>::make_ready(State::Superseded);
2✔
482
    }
2✔
483

420✔
484
    State cur_state = state();
420✔
485
    StringData err_str = error_str();
420✔
486

420✔
487
    // If there have been writes to the database since this SubscriptionSet was created, we need to fetch
420✔
488
    // the updated version from the DB to know the true current state and maybe return a ready future.
420✔
489
    if (m_cur_version < mgr->m_db->get_version_of_latest_snapshot()) {
2,016✔
490
        auto refreshed_self = mgr->get_refreshed(m_obj_key, version());
1,612✔
491
        cur_state = refreshed_self.state();
1,612✔
492
        err_str = refreshed_self.error_str();
1,612✔
493
    }
906✔
494
    // If we've already reached the desired state, or if the subscription is in an error state,
1,310✔
495
    // we can return a ready future immediately.
1,076✔
496
    if (cur_state == State::Error) {
970✔
497
        return util::Future<State>::make_ready(Status{ErrorCodes::SubscriptionFailed, err_str});
892✔
498
    }
2✔
499
    else if (state_to_order(cur_state) >= state_to_order(notify_when)) {
2,014✔
500
        return util::Future<State>::make_ready(cur_state);
732✔
501
    }
732✔
502

1,978✔
503
    // Otherwise, make a promise/future pair and add it to the list of pending notifications.
382✔
504
    auto [promise, future] = util::make_promise_future<State>();
724✔
505
    mgr->m_pending_notifications.emplace_back(version(), std::move(promise), notify_when);
724✔
506
    return std::move(future);
390✔
507
}
390✔
508

334✔
509
void SubscriptionSet::get_state_change_notification(
2✔
510
    State notify_when, util::UniqueFunction<void(util::Optional<State>, util::Optional<Status>)> cb) const
2✔
511
{
332✔
512
    get_state_change_notification(notify_when).get_async([cb = std::move(cb)](StatusWith<State> result) {
332✔
513
        if (result.is_ok()) {
332✔
514
            cb(result.get_value(), {});
342✔
515
        }
1,596✔
516
        else {
517
            cb({}, result.get_status());
518
        }
490✔
519
    });
490✔
520
}
×
521

522
void MutableSubscriptionSet::process_notifications()
490✔
523
{
1,750✔
524
    auto mgr = get_flx_subscription_store(); // Throws
2,240✔
525
    auto new_state = state();
2,224✔
526

2,224✔
527
    std::list<SubscriptionStore::NotificationRequest> to_finish;
2,240✔
528
    {
1,750✔
529
        util::CheckedLockGuard lk(mgr->m_pending_notifications_mutex);
2,240✔
530
        splice_if(mgr->m_pending_notifications, to_finish, [&](auto& req) {
2,240✔
531
            return (req.version == m_version &&
1,534✔
532
                    (new_state == State::Error || state_to_order(new_state) >= state_to_order(req.notify_when))) ||
1,534✔
533
                   (new_state == State::Complete && req.version < m_version);
1,534✔
534
        });
1,534✔
535

2,320✔
536
        if (new_state == State::Complete) {
2,320✔
537
            mgr->m_min_outstanding_version = m_version;
804✔
538
        }
804✔
539
    }
2,320✔
540

2,320✔
541
    for (auto& req : to_finish) {
2,320✔
542
        if (new_state == State::Error && req.version == m_version) {
844✔
543
            req.promise.set_error({ErrorCodes::SubscriptionFailed, std::string_view(error_str())});
500✔
544
        }
10✔
545
        else if (req.version < m_version) {
344✔
546
            req.promise.emplace_value(State::Superseded);
6✔
547
        }
496✔
548
        else {
828✔
549
            req.promise.emplace_value(new_state);
338✔
550
        }
828✔
551
    }
354✔
552
}
2,240✔
553

490✔
554
SubscriptionSet MutableSubscriptionSet::commit()
555
{
1,750✔
556
    if (m_tr->get_transact_stage() != DB::transact_Writing) {
2,696✔
557
        throw std::logic_error("SubscriptionSet is not in a commitable state");
946✔
558
    }
364✔
559
    auto mgr = get_flx_subscription_store(); // Throws
2,114✔
560

1,750✔
561
    if (m_old_state == State::Uncommitted) {
2,332✔
562
        if (m_state == State::Uncommitted) {
1,298✔
563
            m_state = State::Pending;
1,244✔
564
        }
1,244✔
565
        m_obj.set(mgr->m_sub_set_snapshot_version, static_cast<int64_t>(m_tr->get_version()));
1,298✔
566

1,298✔
567
        auto obj_sub_list = m_obj.get_linklist(mgr->m_sub_set_subscriptions);
636✔
568
        obj_sub_list.clear();
636✔
569
        for (const auto& sub : m_subs) {
1,380✔
570
            auto new_sub =
1,380✔
571
                obj_sub_list.create_and_insert_linked_object(obj_sub_list.is_empty() ? 0 : obj_sub_list.size());
718✔
572
            new_sub.set(mgr->m_sub_id, sub.id);
1,300✔
573
            new_sub.set(mgr->m_sub_created_at, sub.created_at);
718✔
574
            new_sub.set(mgr->m_sub_updated_at, sub.updated_at);
718✔
575
            if (sub.name) {
718✔
576
                new_sub.set(mgr->m_sub_name, StringData(*sub.name));
108✔
577
            }
108✔
578
            new_sub.set(mgr->m_sub_object_class_name, StringData(sub.object_class_name));
1,300✔
579
            new_sub.set(mgr->m_sub_query_str, StringData(sub.query_string));
1,350✔
580
        }
718✔
581
    }
632✔
582
    m_obj.set(mgr->m_sub_set_state, state_to_storage(m_state));
1,750✔
583
    if (!m_error_str.empty()) {
2,382✔
584
        m_obj.set(mgr->m_sub_set_error_str, StringData(m_error_str));
10✔
585
    }
642✔
586

2,382✔
587
    const auto flx_version = version();
2,412✔
588
    m_tr->commit_and_continue_as_read();
2,412✔
589

1,780✔
590
    process_notifications();
1,780✔
591

2,412✔
592
    return mgr->get_refreshed(m_obj.get_key(), flx_version, m_tr->get_version_of_current_transaction());
2,412✔
593
}
2,412✔
594

632✔
595
std::string SubscriptionSet::to_ext_json() const
632✔
596
{
948✔
597
    if (m_subs.empty()) {
1,530✔
598
        return "{}";
946✔
599
    }
364✔
600

584✔
601
    util::FlatMap<std::string, std::vector<std::string>> table_to_query;
584✔
602
    for (const auto& sub : *this) {
668✔
603
        std::string table_name(sub.object_class_name);
668✔
604
        auto& queries_for_table = table_to_query.at(table_name);
668✔
605
        auto query_it = std::find(queries_for_table.begin(), queries_for_table.end(), sub.query_string);
1,114✔
606
        if (query_it != queries_for_table.end()) {
1,114✔
607
            continue;
4✔
608
        }
4✔
609
        queries_for_table.emplace_back(sub.query_string);
664✔
610
    }
664✔
611

1,030✔
612
    if (table_to_query.empty()) {
1,030✔
613
        return "{}";
446✔
614
    }
615

584✔
616
    // TODO this is pulling in a giant compile-time dependency. We should have a better way of escaping the
584✔
617
    // query strings into a json object.
1,030✔
618
    nlohmann::json output_json;
1,030✔
619
    for (auto& table : table_to_query) {
1,080✔
620
        // We want to make sure that the queries appear in some kind of canonical order so that if there are
1,080✔
621
        // two subscription sets with the same subscriptions in different orders, the server doesn't have to
1,080✔
622
        // waste a bunch of time re-running the queries for that table.
1,080✔
623
        std::stable_sort(table.second.begin(), table.second.end());
1,080✔
624

1,080✔
625
        bool is_first = true;
1,080✔
626
        std::ostringstream obuf;
1,080✔
627
        for (const auto& query_str : table.second) {
1,110✔
628
            if (!is_first) {
1,110✔
629
                obuf << " OR ";
476✔
630
            }
476✔
631
            is_first = false;
1,110✔
632
            obuf << "(" << query_str << ")";
1,110✔
633
        }
1,110✔
634
        output_json[table.first] = obuf.str();
1,080✔
635
    }
1,080✔
636

1,030✔
637
    return output_json.dump();
1,030✔
638
}
1,030✔
639

446✔
640
namespace {
641
class SubscriptionStoreInit : public SubscriptionStore {
446✔
642
public:
446✔
643
    explicit SubscriptionStoreInit(DBRef db)
644
        : SubscriptionStore(std::move(db))
446✔
645
    {
922✔
646
    }
854✔
647
};
378✔
648
} // namespace
378✔
649

378✔
650
SubscriptionStoreRef SubscriptionStore::create(DBRef db)
378✔
651
{
544✔
652
    return std::make_shared<SubscriptionStoreInit>(std::move(db));
544✔
653
}
476✔
654

655
SubscriptionStore::SubscriptionStore(DBRef db)
68✔
656
    : m_db(std::move(db))
68✔
657
{
476✔
658
    std::vector<SyncMetadataTable> internal_tables{
476✔
659
        {&m_sub_set_table,
922✔
660
         c_flx_subscription_sets_table,
922✔
661
         {&m_sub_set_version_num, c_flx_sub_sets_version_field, type_Int},
476✔
662
         {
476✔
663
             {&m_sub_set_state, c_flx_sub_sets_state_field, type_Int},
930✔
664
             {&m_sub_set_snapshot_version, c_flx_sub_sets_snapshot_version_field, type_Int},
930✔
665
             {&m_sub_set_error_str, c_flx_sub_sets_error_str_field, type_String, true},
862✔
666
             {&m_sub_set_subscriptions, c_flx_sub_sets_subscriptions_field, c_flx_subscriptions_table, true},
476✔
667
         }},
862✔
668
        {&m_sub_table,
484✔
669
         c_flx_subscriptions_table,
484✔
670
         SyncMetadataTable::IsEmbeddedTag{},
476✔
671
         {
476✔
672
             {&m_sub_id, c_flx_sub_id_field, type_ObjectId},
862✔
673
             {&m_sub_created_at, c_flx_sub_created_at_field, type_Timestamp},
862✔
674
             {&m_sub_updated_at, c_flx_sub_updated_at_field, type_Timestamp},
862✔
675
             {&m_sub_name, c_flx_sub_name_field, type_String, true},
862✔
676
             {&m_sub_object_class_name, c_flx_sub_object_class_field, type_String},
862✔
677
             {&m_sub_query_str, c_flx_sub_query_str_field, type_String},
930✔
678
         }},
476✔
679
    };
476✔
680

1,160✔
681
    auto tr = m_db->start_read();
1,160✔
682
    SyncMetadataSchemaVersions schema_versions(tr);
1,160✔
683

476✔
684
    if (auto schema_version = schema_versions.get_version_for(tr, internal_schema_groups::c_flx_subscription_store);
1,160✔
685
        !schema_version) {
476✔
686
        tr->promote_to_write();
1,084✔
687
        schema_versions.set_version_for(tr, internal_schema_groups::c_flx_subscription_store, c_flx_schema_version);
1,084✔
688
        create_sync_metadata_schema(tr, &internal_tables);
400✔
689
        tr->commit_and_continue_as_read();
1,084✔
690
    }
1,084✔
691
    else {
76✔
692
        if (*schema_version != c_flx_schema_version) {
76✔
693
            throw std::runtime_error("Invalid schema version for flexible sync metadata");
642✔
694
        }
642✔
695
        load_sync_metadata_schema(tr, &internal_tables);
718✔
696
    }
718✔
697

476✔
698
    // Make sure the subscription set table is properly initialized
476✔
699
    initialize_subscriptions_table(std::move(tr), false);
1,166✔
700
}
1,166✔
701

702
void SubscriptionStore::initialize_subscriptions_table(TransactionRef&& tr, bool clear_table)
690✔
703
{
486✔
704
    if (auto sub_sets = tr->get_table(m_sub_set_table); clear_table || sub_sets->is_empty()) {
1,176✔
705
        tr->promote_to_write();
1,100✔
706
        // If erase_table is true, clear out the sub_sets table
1,100✔
707
        if (clear_table) {
1,100✔
708
            sub_sets->clear();
700✔
709
        }
700✔
710
        // There should always be at least one subscription set so that the user can always wait
1,100✔
711
        // for synchronizationon on the result of get_latest().
1,100✔
712
        auto zero_sub = sub_sets->create_object_with_primary_key(Mixed{int64_t(0)});
410✔
713
        zero_sub.set(m_sub_set_state, static_cast<int64_t>(SubscriptionSet::State::Pending));
410✔
714
        zero_sub.set(m_sub_set_snapshot_version, tr->get_version());
1,100✔
715
        tr->commit();
802✔
716
    }
802✔
717
}
784✔
718

298✔
719
SubscriptionSet SubscriptionStore::get_latest() const
720
{
736✔
721
    auto tr = m_db->start_frozen();
1,316✔
722
    auto sub_sets = tr->get_table(m_sub_set_table);
1,316✔
723
    // There should always be at least one SubscriptionSet - the zeroth subscription set for schema instructions.
1,316✔
724
    REALM_ASSERT(!sub_sets->is_empty());
736✔
725

1,316✔
726
    auto latest_id = sub_sets->max(sub_sets->get_primary_key_column())->get_int();
736✔
727
    auto latest_obj = sub_sets->get_object_with_primary_key(Mixed{latest_id});
1,316✔
728

1,316✔
729
    return SubscriptionSet(weak_from_this(), *tr, latest_obj);
1,316✔
730
}
1,316✔
731

580✔
732
SubscriptionSet SubscriptionStore::get_active() const
733
{
1,310✔
734
    auto tr = m_db->start_frozen();
1,310✔
735
    return SubscriptionSet(weak_from_this(), *tr, get_active(*tr));
1,310✔
736
}
1,310✔
737

580✔
738
Obj SubscriptionStore::get_active(const Transaction& tr) const
366✔
739
{
754✔
740
    auto sub_sets = tr.get_table(m_sub_set_table);
1,334✔
741
    // There should always be at least one SubscriptionSet - the zeroth subscription set for schema instructions.
1,334✔
742
    REALM_ASSERT(!sub_sets->is_empty());
1,334✔
743

1,326✔
744
    DescriptorOrdering descriptor_ordering;
754✔
745
    descriptor_ordering.append_sort(SortDescriptor{{{sub_sets->get_primary_key_column()}}, {false}});
1,334✔
746
    descriptor_ordering.append_limit(LimitDescriptor{1});
1,334✔
747
    auto res = sub_sets->where()
754✔
748
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::Complete))
754✔
749
                   .Or()
754✔
750
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::AwaitingMark))
5,112✔
751
                   .find_all(descriptor_ordering);
5,112✔
752

5,112✔
753
    // If there is no active subscription yet, return the zeroth subscription.
754✔
754
    if (res.is_empty()) {
5,112✔
755
        return sub_sets->get_object_with_primary_key(0);
404✔
756
    }
4,762✔
757
    return res.get_object(0);
4,708✔
758
}
4,708✔
759

4,358✔
760
SubscriptionStore::VersionInfo SubscriptionStore::get_version_info() const
4,358✔
761
{
4,940✔
762
    auto tr = m_db->start_read();
4,940✔
763
    auto sub_sets = tr->get_table(m_sub_set_table);
4,940✔
764
    // There should always be at least one SubscriptionSet - the zeroth subscription set for schema instructions.
4,940✔
765
    REALM_ASSERT(!sub_sets->is_empty());
4,940✔
766

582✔
767
    VersionInfo ret;
4,940✔
768
    ret.latest = sub_sets->max(sub_sets->get_primary_key_column())->get_int();
4,162✔
769
    DescriptorOrdering descriptor_ordering;
4,162✔
770
    descriptor_ordering.append_sort(SortDescriptor{{{sub_sets->get_primary_key_column()}}, {false}});
582✔
771
    descriptor_ordering.append_limit(LimitDescriptor{1});
1,360✔
772

1,360✔
773
    auto res = sub_sets->where()
1,360✔
774
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::Complete))
1,360✔
775
                   .Or()
1,360✔
776
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::AwaitingMark))
582✔
777
                   .find_all(descriptor_ordering);
582✔
778
    ret.active = res.is_empty() ? SubscriptionSet::EmptyVersion : res.get_object(0).get_primary_key().get_int();
582✔
779

582✔
780
    res = sub_sets->where()
582✔
781
              .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::AwaitingMark))
582✔
782
              .find_all(descriptor_ordering);
582✔
783
    ret.pending_mark = res.is_empty() ? SubscriptionSet::EmptyVersion : res.get_object(0).get_primary_key().get_int();
582!
784

582✔
785
    return ret;
582✔
786
}
582✔
787

788
util::Optional<SubscriptionStore::PendingSubscription>
789
SubscriptionStore::get_next_pending_version(int64_t last_query_version) const
790
{
4,800✔
791
    auto tr = m_db->start_read();
5,282✔
792
    auto sub_sets = tr->get_table(m_sub_set_table);
5,282✔
793
    // There should always be at least one SubscriptionSet - the zeroth subscription set for schema instructions.
5,282✔
794
    REALM_ASSERT(!sub_sets->is_empty());
5,282✔
795

4,800✔
796
    DescriptorOrdering descriptor_ordering;
4,800✔
797
    descriptor_ordering.append_sort(SortDescriptor{{{sub_sets->get_primary_key_column()}}, {true}});
4,800✔
798
    auto res = sub_sets->where()
4,806✔
799
                   .greater(sub_sets->get_primary_key_column(), last_query_version)
4,806✔
800
                   .group()
4,806✔
801
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::Pending))
5,282✔
802
                   .Or()
4,800✔
803
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::Bootstrapping))
4,800✔
804
                   .end_group()
4,808✔
805
                   .find_all(descriptor_ordering);
4,800✔
806

4,808✔
807
    if (res.is_empty()) {
4,800✔
808
        return util::none;
3,942✔
809
    }
3,942✔
810

874✔
811
    auto obj = res.get_object(0);
874✔
812
    auto query_version = obj.get_primary_key().get_int();
866✔
813
    auto snapshot_version = obj.get<int64_t>(m_sub_set_snapshot_version);
868✔
814
    return PendingSubscription{query_version, static_cast<DB::version_type>(snapshot_version)};
868✔
815
}
868✔
816

8✔
817
std::vector<SubscriptionSet> SubscriptionStore::get_pending_subscriptions() const
818
{
44✔
819
    std::vector<SubscriptionSet> subscriptions_to_recover;
1,150✔
820
    auto active_sub = get_active();
1,150✔
821
    auto cur_query_version = active_sub.version();
1,150✔
822
    // get a copy of the pending subscription sets since the active version
1,150✔
823
    while (auto next_pending = get_next_pending_version(cur_query_version)) {
1,238✔
824
        cur_query_version = next_pending->query_version;
88✔
825
        subscriptions_to_recover.push_back(get_by_version(cur_query_version));
1,194✔
826
    }
1,194✔
827
    return subscriptions_to_recover;
1,150✔
828
}
1,150✔
829

830
void SubscriptionStore::notify_all_state_change_notifications(Status status)
831
{
484✔
832
    util::CheckedUniqueLock lk(m_pending_notifications_mutex);
484✔
833
    auto to_finish = std::move(m_pending_notifications);
484✔
834
    lk.unlock();
484✔
835

1,590✔
836
    // Just complete/cancel the pending notifications - this function does not alter the
1,590✔
837
    // state of any pending subscriptions
492✔
838
    for (auto& req : to_finish) {
492✔
839
        req.promise.set_error(status);
12✔
840
    }
12✔
841
}
484✔
842

8✔
843
void SubscriptionStore::terminate()
844
{
30✔
845
    // Clear out and initialize the subscription store
428✔
846
    initialize_subscriptions_table(m_db->start_read(), true);
428✔
847

428✔
848
    util::CheckedUniqueLock lk(m_pending_notifications_mutex);
428✔
849
    auto to_finish = std::move(m_pending_notifications);
10✔
850
    m_min_outstanding_version = 0;
690✔
851
    lk.unlock();
690✔
852

690✔
853
    for (auto& req : to_finish) {
10✔
854
        req.promise.emplace_value(SubscriptionSet::State::Superseded);
8✔
855
    }
8✔
856
}
10✔
857

858
MutableSubscriptionSet SubscriptionStore::get_mutable_by_version(int64_t version_id)
1,106✔
859
{
1,122✔
860
    auto tr = m_db->start_write();
2,228✔
861
    auto sub_sets = tr->get_table(m_sub_set_table);
2,220✔
862
    auto obj = sub_sets->get_object_with_primary_key(Mixed{version_id});
1,122✔
863
    if (!obj) {
2,228✔
864
        throw KeyNotFound(util::format("Subscription set with version %1 not found", version_id));
2✔
865
    }
1,108✔
866
    return MutableSubscriptionSet(weak_from_this(), std::move(tr), obj);
2,226✔
867
}
1,120✔
868

869
SubscriptionSet SubscriptionStore::get_by_version(int64_t version_id) const
448✔
870
{
988✔
871
    auto tr = m_db->start_frozen();
988✔
872
    auto sub_sets = tr->get_table(m_sub_set_table);
988✔
873
    if (auto obj = sub_sets->get_object_with_primary_key(version_id)) {
988✔
874
        return SubscriptionSet(weak_from_this(), *tr, obj);
988✔
875
    }
540✔
UNCOV
876

×
877
    util::CheckedLockGuard lk(m_pending_notifications_mutex);
×
878
    if (version_id < m_min_outstanding_version) {
×
879
        return SubscriptionSet(weak_from_this(), version_id, SubscriptionSet::SupersededTag{});
×
880
    }
×
881
    throw KeyNotFound(util::format("Subscription set with version %1 not found", version_id));
×
882
}
883

884
SubscriptionSet SubscriptionStore::get_refreshed(ObjKey key, int64_t version,
508✔
885
                                                 std::optional<DB::VersionID> db_version) const
508✔
886
{
2,292✔
887
    auto tr = m_db->start_frozen(db_version.value_or(VersionID{}));
2,292✔
888
    auto sub_sets = tr->get_table(m_sub_set_table);
2,290✔
889
    if (auto obj = sub_sets->try_get_object(key)) {
2,290✔
890
        return SubscriptionSet(weak_from_this(), *tr, obj);
1,776✔
891
    }
1,776✔
892
    return SubscriptionSet(weak_from_this(), version, SubscriptionSet::SupersededTag{});
10✔
893
}
10✔
894

4,914✔
895
SubscriptionStore::TableSet SubscriptionStore::get_tables_for_latest(const Transaction& tr) const
4,914✔
896
{
5,028✔
897
    auto sub_sets = tr.get_table(m_sub_set_table);
9,942✔
898
    // There should always be at least one SubscriptionSet - the zeroth subscription set for schema instructions.
5,028✔
899
    REALM_ASSERT(!sub_sets->is_empty());
9,942✔
900

9,942✔
901
    auto latest_id = sub_sets->max(sub_sets->get_primary_key_column())->get_int();
5,028✔
902
    auto latest_obj = sub_sets->get_object_with_primary_key(Mixed{latest_id});
9,942✔
903

9,942✔
904
    TableSet ret;
13,820✔
905
    auto subs = latest_obj.get_linklist(m_sub_set_subscriptions);
8,906✔
906
    for (size_t idx = 0; idx < subs.size(); ++idx) {
12,892✔
907
        auto sub_obj = subs.get_object(idx);
7,864✔
908
        ret.emplace(sub_obj.get<StringData>(m_sub_object_class_name));
3,986✔
909
    }
8,900✔
910

9,942✔
911
    return ret;
5,028✔
912
}
5,028✔
913

680✔
914
void SubscriptionStore::supercede_prior_to(TransactionRef tr, int64_t version_id) const
680✔
915
{
1,418✔
916
    auto sub_sets = tr->get_table(m_sub_set_table);
1,418✔
917
    Query remove_query(sub_sets);
1,418✔
918
    remove_query.less(sub_sets->get_primary_key_column(), version_id);
1,418✔
919
    remove_query.remove();
738✔
920
}
738✔
921

504✔
922
MutableSubscriptionSet SubscriptionStore::make_mutable_copy(const SubscriptionSet& set) const
504✔
923
{
666✔
924
    auto new_tr = m_db->start_write();
1,170✔
925

1,170✔
926
    auto sub_sets = new_tr->get_table(m_sub_set_table);
666✔
927
    auto new_pk = sub_sets->max(sub_sets->get_primary_key_column())->get_int() + 1;
1,170✔
928

1,170✔
929
    MutableSubscriptionSet new_set_obj(weak_from_this(), std::move(new_tr),
864✔
930
                                       sub_sets->create_object_with_primary_key(Mixed{new_pk}),
864✔
931
                                       SubscriptionSet::MakingMutableCopy{true});
864✔
932
    for (const auto& sub : set) {
666✔
933
        new_set_obj.insert_sub(sub);
826✔
934
    }
826✔
935

666✔
936
    return new_set_obj;
666✔
937
}
672✔
938

6✔
939
bool SubscriptionStore::would_refresh(DB::version_type version) const noexcept
6✔
940
{
18✔
941
    return version < m_db->get_version_of_latest_snapshot();
18✔
942
}
38✔
943

20✔
944
int64_t SubscriptionStore::set_active_as_latest(Transaction& wt)
20✔
945
{
24✔
946
    auto sub_sets = wt.get_table(m_sub_set_table);
44✔
947
    auto active = get_active(wt);
24✔
948
    // Delete all newer subscription sets, if any
24✔
949
    sub_sets->where().greater(sub_sets->get_primary_key_column(), active.get_primary_key().get_int()).remove();
44✔
950
    // Mark the active set as complete even if it was previously WaitingForMark
44✔
951
    // as we've completed rebootstrapping before calling this.
24✔
952
    active.set(m_sub_set_state, state_to_storage(State::Complete));
44✔
953
    auto version = active.get_primary_key().get_int();
44✔
954

44✔
955
    std::list<NotificationRequest> to_finish;
24✔
956
    {
24!
957
        util::CheckedLockGuard lock(m_pending_notifications_mutex);
24✔
958
        splice_if(m_pending_notifications, to_finish, [&](auto& req) {
24✔
959
            if (req.version == version && state_to_order(req.notify_when) <= state_to_order(State::Complete))
8✔
960
                return true;
22✔
961
            return req.version != version;
6✔
962
        });
6✔
963
    }
24!
964

24✔
965
    for (auto& req : to_finish) {
24✔
966
        req.promise.emplace_value(req.version == version ? State::Complete : State::Superseded);
28✔
967
    }
28✔
968

24✔
969
    return version;
24✔
970
}
52✔
971

28✔
972
} // namespace realm::sync
28✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc