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

realm / realm-core / 1873

27 Nov 2023 09:40PM UTC coverage: 91.661% (-0.03%) from 91.695%
1873

push

Evergreen

web-flow
Fix a bunch of throw statements to use Realm exceptions (#7141)

* Fix a bunch of throw statements to use Realm exceptions
* check correct exception in test
* clang-format and replaced a couple of std::exceptions in SyncManager
* Updated changelog

---------

Co-authored-by: Jonathan Reams <jbreams@mongodb.com>
Co-authored-by: Jørgen Edelbo <jorgen.edelbo@mongodb.com>

92402 of 169340 branches covered (0.0%)

2 of 77 new or added lines in 7 files covered. (2.6%)

113 existing lines in 15 files now uncovered.

231821 of 252910 relevant lines covered (91.66%)

6407488.93 hits per line

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

93.82
/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
{
9,838✔
77
    switch (static_cast<SubscriptionStateForStorage>(value)) {
9,838✔
78
        case SubscriptionStateForStorage::Pending:
5,526✔
79
            return SubscriptionSet::State::Pending;
5,526✔
80
        case SubscriptionStateForStorage::Bootstrapping:
124✔
81
            return SubscriptionSet::State::Bootstrapping;
124✔
82
        case SubscriptionStateForStorage::AwaitingMark:
1,532✔
83
            return SubscriptionSet::State::AwaitingMark;
1,532✔
84
        case SubscriptionStateForStorage::Complete:
2,620✔
85
            return SubscriptionSet::State::Complete;
2,620✔
86
        case SubscriptionStateForStorage::Error:
36✔
87
            return SubscriptionSet::State::Error;
36✔
88
        default:
✔
NEW
89
            throw RuntimeError(ErrorCodes::InvalidArgument,
×
NEW
90
                               util::format("Invalid state for SubscriptionSet stored on disk: %1", value));
×
91
    }
9,838✔
92
}
9,838✔
93

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

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

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

147
} // namespace
148

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

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

170

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

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

191
void SubscriptionSet::load_from_database(const Obj& obj)
192
{
9,838✔
193
    auto mgr = get_flx_subscription_store(); // Throws
9,838✔
194

4,912✔
195
    m_state = state_from_storage(obj.get<int64_t>(mgr->m_sub_set_state));
9,838✔
196
    m_error_str = obj.get<String>(mgr->m_sub_set_error_str);
9,838✔
197
    m_snapshot_version = static_cast<DB::version_type>(obj.get<int64_t>(mgr->m_sub_set_snapshot_version));
9,838✔
198
    auto sub_list = obj.get_linklist(mgr->m_sub_set_subscriptions);
9,838✔
199
    m_subs.clear();
9,838✔
200
    for (size_t idx = 0; idx < sub_list.size(); ++idx) {
17,716✔
201
        m_subs.push_back(Subscription(mgr.get(), sub_list.get_object(idx)));
7,878✔
202
    }
7,878✔
203
}
9,838✔
204

205
std::shared_ptr<const SubscriptionStore> SubscriptionSet::get_flx_subscription_store() const
206
{
20,598✔
207
    if (auto mgr = m_mgr.lock()) {
20,598✔
208
        return mgr;
20,596✔
209
    }
20,596✔
210
    throw RuntimeError(ErrorCodes::BrokenInvariant, "Active SubscriptionSet without a SubscriptionStore");
2✔
211
}
2✔
212

213
int64_t SubscriptionSet::version() const
214
{
13,266✔
215
    return m_version;
13,266✔
216
}
13,266✔
217

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

223
SubscriptionSet::State SubscriptionSet::state() const
224
{
10,534✔
225
    return m_state;
10,534✔
226
}
10,534✔
227

228
StringData SubscriptionSet::error_str() const
229
{
908✔
230
    if (m_error_str.empty()) {
908✔
231
        return StringData{};
880✔
232
    }
880✔
233
    return m_error_str;
28✔
234
}
28✔
235

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

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

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

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

256
const Subscription* SubscriptionSet::find(StringData name) const
257
{
64✔
258
    for (auto&& sub : *this) {
92✔
259
        if (sub.name == name)
92✔
260
            return &sub;
48✔
261
    }
92✔
262
    return nullptr;
40✔
263
}
64✔
264

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

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

285
void MutableSubscriptionSet::check_is_mutable() const
286
{
4,672✔
287
    if (m_tr->get_transact_stage() != DB::transact_Writing) {
4,672✔
288
        throw WrongTransactionState("Not a write transaction");
12✔
289
    }
12✔
290
}
4,672✔
291

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

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

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

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

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

357
void MutableSubscriptionSet::clear()
358
{
376✔
359
    check_is_mutable();
376✔
360
    m_subs.clear();
376✔
361
}
376✔
362

363
void MutableSubscriptionSet::insert_sub(const Subscription& sub)
364
{
844✔
365
    check_is_mutable();
844✔
366
    m_subs.push_back(sub);
844✔
367
}
844✔
368

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

20✔
380
        return {it, false};
40✔
381
    }
40✔
382
    it = m_subs.insert(m_subs.end(),
962✔
383
                       Subscription(std::move(name), std::move(object_class_name), std::move(query_str)));
962✔
384

480✔
385
    return {it, true};
962✔
386
}
962✔
387

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

84✔
397
    return insert_or_assign_impl(it, std::string{name}, std::move(table_name), std::move(query_str));
168✔
398
}
168✔
399

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

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

411
void MutableSubscriptionSet::import(const SubscriptionSet& src_subs)
412
{
128✔
413
    clear();
128✔
414
    for (const Subscription& sub : src_subs) {
136✔
415
        insert_sub(sub);
136✔
416
    }
136✔
417
}
128✔
418

419
void MutableSubscriptionSet::update_state(State new_state, util::Optional<std::string_view> error_str)
420
{
2,358✔
421
    check_is_mutable();
2,358✔
422
    auto old_state = state();
2,358✔
423
    if (error_str && new_state != State::Error) {
2,358✔
NEW
424
        throw LogicError(ErrorCodes::InvalidArgument,
×
NEW
425
                         "Cannot supply an error message for a subscription set when state is not Error");
×
UNCOV
426
    }
×
427
    switch (new_state) {
2,358✔
428
        case State::Uncommitted:
✔
NEW
429
            throw LogicError(ErrorCodes::InvalidArgument, "cannot set subscription set state to uncommitted");
×
430

431
        case State::Error:
20✔
432
            if (old_state != State::Bootstrapping && old_state != State::Pending && old_state != State::Uncommitted) {
20!
NEW
433
                throw LogicError(ErrorCodes::InvalidArgument,
×
NEW
434
                                 "subscription set must be in Bootstrapping or Pending to update state to error");
×
UNCOV
435
            }
×
436
            if (!error_str) {
20✔
NEW
437
                throw LogicError(ErrorCodes::InvalidArgument,
×
NEW
438
                                 "Must supply an error message when setting a subscription to the error state");
×
UNCOV
439
            }
×
440

10✔
441
            m_state = new_state;
20✔
442
            m_error_str = std::string{*error_str};
20✔
443
            break;
20✔
444
        case State::Bootstrapping:
52✔
445
            [[fallthrough]];
52✔
446
        case State::AwaitingMark:
852✔
447
            m_state = new_state;
852✔
448
            break;
852✔
449
        case State::Complete: {
1,486✔
450
            auto mgr = get_flx_subscription_store(); // Throws
1,486✔
451
            m_state = new_state;
1,486✔
452
            mgr->supercede_prior_to(m_tr, version());
1,486✔
453
            break;
1,486✔
454
        }
52✔
455
        case State::Superseded:
26✔
NEW
456
            throw LogicError(ErrorCodes::InvalidArgument, "Cannot set a subscription to the superseded state");
×
457
        case State::Pending:
26✔
NEW
458
            throw LogicError(ErrorCodes::InvalidArgument, "Cannot set subscription set to the pending state");
×
459
    }
2,358✔
460
}
2,358✔
461

462
MutableSubscriptionSet SubscriptionSet::make_mutable_copy() const
463
{
1,350✔
464
    auto mgr = get_flx_subscription_store(); // Throws
1,350✔
465
    return mgr->make_mutable_copy(*this);
1,350✔
466
}
1,350✔
467

468
void SubscriptionSet::refresh()
469
{
40✔
470
    auto mgr = get_flx_subscription_store(); // Throws
40✔
471
    if (mgr->would_refresh(m_cur_version)) {
40✔
472
        *this = mgr->get_refreshed(m_obj_key, version());
36✔
473
    }
36✔
474
}
40✔
475

476
util::Future<SubscriptionSet::State> SubscriptionSet::get_state_change_notification(State notify_when) const
477
{
844✔
478
    auto mgr = get_flx_subscription_store(); // Throws
844✔
479

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

420✔
487
    State cur_state = state();
840✔
488
    StringData err_str = error_str();
840✔
489

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

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

512
void SubscriptionSet::get_state_change_notification(
513
    State notify_when, util::UniqueFunction<void(util::Optional<State>, util::Optional<Status>)> cb) const
514
{
×
515
    get_state_change_notification(notify_when).get_async([cb = std::move(cb)](StatusWith<State> result) {
×
516
        if (result.is_ok()) {
×
517
            cb(result.get_value(), {});
×
518
        }
×
519
        else {
×
520
            cb({}, result.get_status());
×
521
        }
×
522
    });
×
523
}
×
524

525
void MutableSubscriptionSet::process_notifications()
526
{
3,520✔
527
    auto mgr = get_flx_subscription_store(); // Throws
3,520✔
528
    auto new_state = state();
3,520✔
529

1,756✔
530
    std::list<SubscriptionStore::NotificationRequest> to_finish;
3,520✔
531
    {
3,520✔
532
        util::CheckedLockGuard lk(mgr->m_pending_notifications_mutex);
3,520✔
533
        splice_if(mgr->m_pending_notifications, to_finish, [&](auto& req) {
2,722✔
534
            return (req.version == m_version &&
1,932✔
535
                    (new_state == State::Error || state_to_order(new_state) >= state_to_order(req.notify_when))) ||
1,638✔
536
                   (new_state == State::Complete && req.version < m_version);
1,584✔
537
        });
1,932✔
538

1,756✔
539
        if (new_state == State::Complete) {
3,520✔
540
            mgr->m_min_outstanding_version = m_version;
1,486✔
541
        }
1,486✔
542
    }
3,520✔
543

1,756✔
544
    for (auto& req : to_finish) {
2,110✔
545
        if (new_state == State::Error && req.version == m_version) {
708✔
546
            req.promise.set_error({ErrorCodes::SubscriptionFailed, std::string_view(error_str())});
20✔
547
        }
20✔
548
        else if (req.version < m_version) {
688✔
549
            req.promise.emplace_value(State::Superseded);
12✔
550
        }
12✔
551
        else {
676✔
552
            req.promise.emplace_value(new_state);
676✔
553
        }
676✔
554
    }
708✔
555
}
3,520✔
556

557
SubscriptionSet MutableSubscriptionSet::commit()
558
{
3,520✔
559
    if (m_tr->get_transact_stage() != DB::transact_Writing) {
3,520✔
NEW
560
        throw RuntimeError(ErrorCodes::WrongTransactionState, "SubscriptionSet is not in a commitable state");
×
561
    }
×
562
    auto mgr = get_flx_subscription_store(); // Throws
3,520✔
563

1,756✔
564
    if (m_old_state == State::Uncommitted) {
3,520✔
565
        if (m_state == State::Uncommitted) {
1,282✔
566
            m_state = State::Pending;
1,166✔
567
        }
1,166✔
568
        m_obj.set(mgr->m_sub_set_snapshot_version, static_cast<int64_t>(m_tr->get_version()));
1,282✔
569

640✔
570
        auto obj_sub_list = m_obj.get_linklist(mgr->m_sub_set_subscriptions);
1,282✔
571
        obj_sub_list.clear();
1,282✔
572
        for (const auto& sub : m_subs) {
1,462✔
573
            auto new_sub =
1,462✔
574
                obj_sub_list.create_and_insert_linked_object(obj_sub_list.is_empty() ? 0 : obj_sub_list.size());
1,336✔
575
            new_sub.set(mgr->m_sub_id, sub.id);
1,462✔
576
            new_sub.set(mgr->m_sub_created_at, sub.created_at);
1,462✔
577
            new_sub.set(mgr->m_sub_updated_at, sub.updated_at);
1,462✔
578
            if (sub.name) {
1,462✔
579
                new_sub.set(mgr->m_sub_name, StringData(*sub.name));
240✔
580
            }
240✔
581
            new_sub.set(mgr->m_sub_object_class_name, StringData(sub.object_class_name));
1,462✔
582
            new_sub.set(mgr->m_sub_query_str, StringData(sub.query_string));
1,462✔
583
        }
1,462✔
584
    }
1,282✔
585
    m_obj.set(mgr->m_sub_set_state, state_to_storage(m_state));
3,520✔
586
    if (!m_error_str.empty()) {
3,520✔
587
        m_obj.set(mgr->m_sub_set_error_str, StringData(m_error_str));
20✔
588
    }
20✔
589

1,756✔
590
    const auto flx_version = version();
3,520✔
591
    m_tr->commit_and_continue_as_read();
3,520✔
592

1,756✔
593
    process_notifications();
3,520✔
594

1,756✔
595
    return mgr->get_refreshed(m_obj.get_key(), flx_version, m_tr->get_version_of_current_transaction());
3,520✔
596
}
3,520✔
597

598
std::string SubscriptionSet::to_ext_json() const
599
{
1,892✔
600
    if (m_subs.empty()) {
1,892✔
601
        return "{}";
728✔
602
    }
728✔
603

584✔
604
    util::FlatMap<std::string, std::vector<std::string>> table_to_query;
1,164✔
605
    for (const auto& sub : *this) {
1,332✔
606
        std::string table_name(sub.object_class_name);
1,332✔
607
        auto& queries_for_table = table_to_query.at(table_name);
1,332✔
608
        auto query_it = std::find(queries_for_table.begin(), queries_for_table.end(), sub.query_string);
1,332✔
609
        if (query_it != queries_for_table.end()) {
1,332✔
610
            continue;
8✔
611
        }
8✔
612
        queries_for_table.emplace_back(sub.query_string);
1,324✔
613
    }
1,324✔
614

584✔
615
    if (table_to_query.empty()) {
1,164✔
616
        return "{}";
×
617
    }
×
618

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

634✔
628
        bool is_first = true;
1,264✔
629
        std::ostringstream obuf;
1,264✔
630
        for (const auto& query_str : table.second) {
1,324✔
631
            if (!is_first) {
1,324✔
632
                obuf << " OR ";
60✔
633
            }
60✔
634
            is_first = false;
1,324✔
635
            obuf << "(" << query_str << ")";
1,324✔
636
        }
1,324✔
637
        output_json[table.first] = obuf.str();
1,264✔
638
    }
1,264✔
639

584✔
640
    return output_json.dump();
1,164✔
641
}
1,164✔
642

643
namespace {
644
class SubscriptionStoreInit : public SubscriptionStore {
645
public:
646
    explicit SubscriptionStoreInit(DBRef db)
647
        : SubscriptionStore(std::move(db))
648
    {
956✔
649
    }
956✔
650
};
651
} // namespace
652

653
SubscriptionStoreRef SubscriptionStore::create(DBRef db)
654
{
956✔
655
    return std::make_shared<SubscriptionStoreInit>(std::move(db));
956✔
656
}
956✔
657

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

478✔
684
    auto tr = m_db->start_read();
956✔
685
    SyncMetadataSchemaVersions schema_versions(tr);
956✔
686

478✔
687
    if (auto schema_version = schema_versions.get_version_for(tr, internal_schema_groups::c_flx_subscription_store);
956✔
688
        !schema_version) {
956✔
689
        tr->promote_to_write();
808✔
690
        schema_versions.set_version_for(tr, internal_schema_groups::c_flx_subscription_store, c_flx_schema_version);
808✔
691
        create_sync_metadata_schema(tr, &internal_tables);
808✔
692
        tr->commit_and_continue_as_read();
808✔
693
    }
808✔
694
    else {
148✔
695
        if (*schema_version != c_flx_schema_version) {
148✔
NEW
696
            throw RuntimeError(ErrorCodes::UnsupportedFileFormatVersion,
×
NEW
697
                               "Invalid schema version for flexible sync metadata");
×
UNCOV
698
        }
×
699
        load_sync_metadata_schema(tr, &internal_tables);
148✔
700
    }
148✔
701

478✔
702
    // Make sure the subscription set table is properly initialized
478✔
703
    initialize_subscriptions_table(std::move(tr), false);
956✔
704
}
956✔
705

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

723
SubscriptionSet SubscriptionStore::get_latest() const
724
{
1,482✔
725
    auto tr = m_db->start_frozen();
1,482✔
726
    auto sub_sets = tr->get_table(m_sub_set_table);
1,482✔
727
    // There should always be at least one SubscriptionSet - the zeroth subscription set for schema instructions.
740✔
728
    REALM_ASSERT(!sub_sets->is_empty());
1,482✔
729

740✔
730
    auto latest_id = sub_sets->max(sub_sets->get_primary_key_column())->get_int();
1,482✔
731
    auto latest_obj = sub_sets->get_object_with_primary_key(Mixed{latest_id});
1,482✔
732

740✔
733
    return SubscriptionSet(weak_from_this(), *tr, latest_obj);
1,482✔
734
}
1,482✔
735

736
SubscriptionSet SubscriptionStore::get_active() const
737
{
1,464✔
738
    auto tr = m_db->start_frozen();
1,464✔
739
    return SubscriptionSet(weak_from_this(), *tr, get_active(*tr));
1,464✔
740
}
1,464✔
741

742
Obj SubscriptionStore::get_active(const Transaction& tr) const
743
{
1,512✔
744
    auto sub_sets = tr.get_table(m_sub_set_table);
1,512✔
745
    // There should always be at least one SubscriptionSet - the zeroth subscription set for schema instructions.
758✔
746
    REALM_ASSERT(!sub_sets->is_empty());
1,512✔
747

758✔
748
    DescriptorOrdering descriptor_ordering;
1,512✔
749
    descriptor_ordering.append_sort(SortDescriptor{{{sub_sets->get_primary_key_column()}}, {false}});
1,512✔
750
    descriptor_ordering.append_limit(LimitDescriptor{1});
1,512✔
751
    auto res = sub_sets->where()
1,512✔
752
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::Complete))
1,512✔
753
                   .Or()
1,512✔
754
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::AwaitingMark))
1,512✔
755
                   .find_all(descriptor_ordering);
1,512✔
756

758✔
757
    // If there is no active subscription yet, return the zeroth subscription.
758✔
758
    if (res.is_empty()) {
1,512✔
759
        return sub_sets->get_object_with_primary_key(0);
808✔
760
    }
808✔
761
    return res.get_object(0);
704✔
762
}
704✔
763

764
SubscriptionStore::VersionInfo SubscriptionStore::get_version_info() const
765
{
1,166✔
766
    auto tr = m_db->start_read();
1,166✔
767
    auto sub_sets = tr->get_table(m_sub_set_table);
1,166✔
768
    // There should always be at least one SubscriptionSet - the zeroth subscription set for schema instructions.
584✔
769
    REALM_ASSERT(!sub_sets->is_empty());
1,166✔
770

584✔
771
    VersionInfo ret;
1,166✔
772
    ret.latest = sub_sets->max(sub_sets->get_primary_key_column())->get_int();
1,166✔
773
    DescriptorOrdering descriptor_ordering;
1,166✔
774
    descriptor_ordering.append_sort(SortDescriptor{{{sub_sets->get_primary_key_column()}}, {false}});
1,166✔
775
    descriptor_ordering.append_limit(LimitDescriptor{1});
1,166✔
776

584✔
777
    auto res = sub_sets->where()
1,166✔
778
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::Complete))
1,166✔
779
                   .Or()
1,166✔
780
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::AwaitingMark))
1,166✔
781
                   .find_all(descriptor_ordering);
1,166✔
782
    ret.active = res.is_empty() ? SubscriptionSet::EmptyVersion : res.get_object(0).get_primary_key().get_int();
950✔
783

584✔
784
    res = sub_sets->where()
1,166✔
785
              .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::AwaitingMark))
1,166✔
786
              .find_all(descriptor_ordering);
1,166✔
787
    ret.pending_mark = res.is_empty() ? SubscriptionSet::EmptyVersion : res.get_object(0).get_primary_key().get_int();
1,158✔
788

584✔
789
    return ret;
1,166✔
790
}
1,166✔
791

792
util::Optional<SubscriptionStore::PendingSubscription>
793
SubscriptionStore::get_next_pending_version(int64_t last_query_version) const
794
{
9,238✔
795
    auto tr = m_db->start_read();
9,238✔
796
    auto sub_sets = tr->get_table(m_sub_set_table);
9,238✔
797
    // There should always be at least one SubscriptionSet - the zeroth subscription set for schema instructions.
4,734✔
798
    REALM_ASSERT(!sub_sets->is_empty());
9,238✔
799

4,734✔
800
    DescriptorOrdering descriptor_ordering;
9,238✔
801
    descriptor_ordering.append_sort(SortDescriptor{{{sub_sets->get_primary_key_column()}}, {true}});
9,238✔
802
    auto res = sub_sets->where()
9,238✔
803
                   .greater(sub_sets->get_primary_key_column(), last_query_version)
9,238✔
804
                   .group()
9,238✔
805
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::Pending))
9,238✔
806
                   .Or()
9,238✔
807
                   .equal(m_sub_set_state, state_to_storage(SubscriptionSet::State::Bootstrapping))
9,238✔
808
                   .end_group()
9,238✔
809
                   .find_all(descriptor_ordering);
9,238✔
810

4,734✔
811
    if (res.is_empty()) {
9,238✔
812
        return util::none;
7,502✔
813
    }
7,502✔
814

868✔
815
    auto obj = res.get_object(0);
1,736✔
816
    auto query_version = obj.get_primary_key().get_int();
1,736✔
817
    auto snapshot_version = obj.get<int64_t>(m_sub_set_snapshot_version);
1,736✔
818
    return PendingSubscription{query_version, static_cast<DB::version_type>(snapshot_version)};
1,736✔
819
}
1,736✔
820

821
std::vector<SubscriptionSet> SubscriptionStore::get_pending_subscriptions() const
822
{
92✔
823
    std::vector<SubscriptionSet> subscriptions_to_recover;
92✔
824
    auto active_sub = get_active();
92✔
825
    auto cur_query_version = active_sub.version();
92✔
826
    // get a copy of the pending subscription sets since the active version
46✔
827
    while (auto next_pending = get_next_pending_version(cur_query_version)) {
272✔
828
        cur_query_version = next_pending->query_version;
180✔
829
        subscriptions_to_recover.push_back(get_by_version(cur_query_version));
180✔
830
    }
180✔
831
    return subscriptions_to_recover;
92✔
832
}
92✔
833

834
void SubscriptionStore::notify_all_state_change_notifications(Status status)
835
{
970✔
836
    util::CheckedUniqueLock lk(m_pending_notifications_mutex);
970✔
837
    auto to_finish = std::move(m_pending_notifications);
970✔
838
    lk.unlock();
970✔
839

486✔
840
    // Just complete/cancel the pending notifications - this function does not alter the
486✔
841
    // state of any pending subscriptions
486✔
842
    for (auto& req : to_finish) {
498✔
843
        req.promise.set_error(status);
24✔
844
    }
24✔
845
}
970✔
846

847
void SubscriptionStore::terminate()
848
{
20✔
849
    // Clear out and initialize the subscription store
10✔
850
    initialize_subscriptions_table(m_db->start_read(), true);
20✔
851

10✔
852
    util::CheckedUniqueLock lk(m_pending_notifications_mutex);
20✔
853
    auto to_finish = std::move(m_pending_notifications);
20✔
854
    m_min_outstanding_version = 0;
20✔
855
    lk.unlock();
20✔
856

10✔
857
    for (auto& req : to_finish) {
18✔
858
        req.promise.emplace_value(SubscriptionSet::State::Superseded);
16✔
859
    }
16✔
860
}
20✔
861

862
MutableSubscriptionSet SubscriptionStore::get_mutable_by_version(int64_t version_id)
863
{
2,246✔
864
    auto tr = m_db->start_write();
2,246✔
865
    auto sub_sets = tr->get_table(m_sub_set_table);
2,246✔
866
    auto obj = sub_sets->get_object_with_primary_key(Mixed{version_id});
2,246✔
867
    if (!obj) {
2,246✔
868
        throw KeyNotFound(util::format("Subscription set with version %1 not found", version_id));
4✔
869
    }
4✔
870
    return MutableSubscriptionSet(weak_from_this(), std::move(tr), obj);
2,242✔
871
}
2,242✔
872

873
SubscriptionSet SubscriptionStore::get_by_version(int64_t version_id) const
874
{
1,084✔
875
    auto tr = m_db->start_frozen();
1,084✔
876
    auto sub_sets = tr->get_table(m_sub_set_table);
1,084✔
877
    if (auto obj = sub_sets->get_object_with_primary_key(version_id)) {
1,084✔
878
        return SubscriptionSet(weak_from_this(), *tr, obj);
1,084✔
879
    }
1,084✔
880

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

888
SubscriptionSet SubscriptionStore::get_refreshed(ObjKey key, int64_t version,
889
                                                 std::optional<DB::VersionID> db_version) const
890
{
3,588✔
891
    auto tr = m_db->start_frozen(db_version.value_or(VersionID{}));
3,588✔
892
    auto sub_sets = tr->get_table(m_sub_set_table);
3,588✔
893
    if (auto obj = sub_sets->try_get_object(key)) {
3,588✔
894
        return SubscriptionSet(weak_from_this(), *tr, obj);
3,568✔
895
    }
3,568✔
896
    return SubscriptionSet(weak_from_this(), version, SubscriptionSet::SupersededTag{});
20✔
897
}
20✔
898

899
SubscriptionStore::TableSet SubscriptionStore::get_tables_for_latest(const Transaction& tr) const
900
{
10,176✔
901
    auto sub_sets = tr.get_table(m_sub_set_table);
10,176✔
902
    // There should always be at least one SubscriptionSet - the zeroth subscription set for schema instructions.
5,080✔
903
    REALM_ASSERT(!sub_sets->is_empty());
10,176✔
904

5,080✔
905
    auto latest_id = sub_sets->max(sub_sets->get_primary_key_column())->get_int();
10,176✔
906
    auto latest_obj = sub_sets->get_object_with_primary_key(Mixed{latest_id});
10,176✔
907

5,080✔
908
    TableSet ret;
10,176✔
909
    auto subs = latest_obj.get_linklist(m_sub_set_subscriptions);
10,176✔
910
    for (size_t idx = 0; idx < subs.size(); ++idx) {
18,284✔
911
        auto sub_obj = subs.get_object(idx);
8,108✔
912
        ret.emplace(sub_obj.get<StringData>(m_sub_object_class_name));
8,108✔
913
    }
8,108✔
914

5,080✔
915
    return ret;
10,176✔
916
}
10,176✔
917

918
void SubscriptionStore::supercede_prior_to(TransactionRef tr, int64_t version_id) const
919
{
1,486✔
920
    auto sub_sets = tr->get_table(m_sub_set_table);
1,486✔
921
    Query remove_query(sub_sets);
1,486✔
922
    remove_query.less(sub_sets->get_primary_key_column(), version_id);
1,486✔
923
    remove_query.remove();
1,486✔
924
}
1,486✔
925

926
MutableSubscriptionSet SubscriptionStore::make_mutable_copy(const SubscriptionSet& set) const
927
{
1,350✔
928
    auto new_tr = m_db->start_write();
1,350✔
929

674✔
930
    auto sub_sets = new_tr->get_table(m_sub_set_table);
1,350✔
931
    auto new_pk = sub_sets->max(sub_sets->get_primary_key_column())->get_int() + 1;
1,350✔
932

674✔
933
    MutableSubscriptionSet new_set_obj(weak_from_this(), std::move(new_tr),
1,350✔
934
                                       sub_sets->create_object_with_primary_key(Mixed{new_pk}),
1,350✔
935
                                       SubscriptionSet::MakingMutableCopy{true});
1,350✔
936
    for (const auto& sub : set) {
1,004✔
937
        new_set_obj.insert_sub(sub);
660✔
938
    }
660✔
939

674✔
940
    return new_set_obj;
1,350✔
941
}
1,350✔
942

943
bool SubscriptionStore::would_refresh(DB::version_type version) const noexcept
944
{
36✔
945
    return version < m_db->get_version_of_latest_snapshot();
36✔
946
}
36✔
947

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

24✔
959
    std::list<NotificationRequest> to_finish;
48✔
960
    {
48✔
961
        util::CheckedLockGuard lock(m_pending_notifications_mutex);
48✔
962
        splice_if(m_pending_notifications, to_finish, [&](auto& req) {
32✔
963
            if (req.version == version && state_to_order(req.notify_when) <= state_to_order(State::Complete))
16✔
964
                return true;
4✔
965
            return req.version != version;
12✔
966
        });
12✔
967
    }
48✔
968

24✔
969
    for (auto& req : to_finish) {
32✔
970
        req.promise.emplace_value(req.version == version ? State::Complete : State::Superseded);
14✔
971
    }
16✔
972

24✔
973
    return version;
48✔
974
}
48✔
975

976
} // namespace realm::sync
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

© 2026 Coveralls, Inc